Send Pay Links
Guides

Webhooks Guide

Set up webhooks to receive real-time notifications when orders are placed

Webhooks Guide

Webhooks notify your server in real-time when events occur—like when a customer completes a purchase, accepts an upsell, or abandons their cart. This guide walks you through setting up and handling webhooks.

How Webhooks Work

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   Customer      │     │  Send Pay Links  │     │   Your Server   │
│   Checkout      │────▶│  Platform        │────▶│   /webhooks     │
└─────────────────┘     └──────────────────┘     └─────────────────┘
        │                        │                        │
        │  Completes payment     │                        │
        │───────────────────────▶│                        │
        │                        │                        │
        │                        │  POST webhook payload  │
        │                        │───────────────────────▶│
        │                        │                        │
        │                        │        200 OK          │
        │                        │◀───────────────────────│
  1. A customer completes an action (e.g., payment)
  2. Send Pay Links creates an event
  3. We POST the event data to your webhook URL
  4. Your server processes it and returns 200 OK

Step 1: Set Up Your Endpoint

Create an HTTPS endpoint on your server to receive webhook events:

// Express.js example
app.post('/webhooks/sendpaylinks', express.json(), (req, res) => {
  const event = req.body;
  
  console.log('Received:', event.type);
  
  // Respond immediately
  res.status(200).send('OK');
  
  // Process async (don't block the response)
  processWebhook(event);
});

Important: Always respond with 200 OK within 30 seconds. Process the webhook data asynchronously to avoid timeouts.


Step 2: Register Your Webhook

Option A: In the Dashboard

  1. Go to SettingsWebhooks
  2. Click Add Endpoint
  3. Enter your URL: https://yoursite.com/webhooks/sendpaylinks
  4. Select which events to receive
  5. Click Save

Option B: Per-Checkout Webhook

Include webhookUrl when generating a checkout token:

{
  "orderId": "order_123",
  "total": 9999,
  "webhookUrl": "https://yoursite.com/webhooks/order-123",
  ...
}

Step 3: Handle Events

Event Types

EventWhen It Fires
checkout.initializedCheckout page loaded
checkout.provider_selectedCustomer selected payment method
payment.succeededPayment completed successfully
payment.failedPayment was declined or failed
order.confirmedOrder finalized by payment provider
upsell.acceptedCustomer accepted an upsell
upsell.declinedCustomer declined an upsell

Example Handler

async function processWebhook(event) {
  switch (event.type) {
    case 'payment.succeeded':
      await fulfillOrder(event.data.object);
      await sendConfirmationEmail(event.data.object.customer);
      break;
      
    case 'upsell.accepted':
      await addUpsellToOrder(event.data.object);
      break;
      
    case 'payment.failed':
      await notifyCustomerOfFailure(event.data.object);
      break;
      
    default:
      console.log('Unhandled event:', event.type);
  }
}

Webhook Payload Structure

Every webhook includes comprehensive order data:

{
  "id": "evt_abc123def456",
  "type": "payment.succeeded",
  "created": 1704067200,
  "livemode": true,
  "api_version": "2024-01-01",
  
  "request": {
    "id": "req_xyz789",
    "idempotency_key": "evt_abc123def456"
  },
  
  "data": {
    "object": {
      "object": "payment",
      "id": "chk_123456",
      "order_id": "order_789",
      "provider_order_id": "pi_stripe123",
      
      "payment": {
        "provider": "stripe",
        "transaction_id": "pi_stripe123",
        "status": "succeeded"
      },
      
      "amount": {
        "subtotal": 4999,
        "shipping": 499,
        "tax": 440,
        "discount": 0,
        "total": 5938,
        "currency": "USD"
      },
      
      "customer": {
        "email": "john@example.com",
        "first_name": "John",
        "last_name": "Doe",
        "phone": "+1234567890"
      },
      
      "shipping_address": {
        "line1": "123 Main St",
        "city": "New York",
        "state": "NY",
        "postal_code": "10001",
        "country": "US"
      },
      
      "line_items": [
        {
          "id": "item_1",
          "product_id": "prod_abc",
          "name": "Premium Widget",
          "quantity": 2,
          "unit_price": 2499,
          "total_price": 4998,
          "sku": "WIDGET-001"
        }
      ],
      
      "metadata": {
        "campaign": "summer_sale"
      },

      "created_at": 1704067200,
      "completed_at": 1704067250
    }
  },

  "webhook_id": "wh_abc123",
  "delivery_attempt": 1,
  "delivered_at": "2024-01-01T12:00:50Z"
}

Step 4: Verify Webhook Signatures

Always verify signatures to ensure webhooks are authentic:

Webhook Headers

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature
X-Webhook-TimestampUnix timestamp when sent
X-Webhook-IdUnique webhook delivery ID
X-Webhook-EventEvent type (e.g., payment.succeeded)
X-Webhook-Delivery-AttemptAttempt number (1, 2, 3...)

Verification Code

import crypto from 'crypto';

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // Use timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// In your webhook handler
app.post('/webhooks/sendpaylinks', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const payload = req.body.toString();

  if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
    console.error('Invalid webhook signature');
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(payload);
  res.status(200).send('OK');

  processWebhook(event);
});

Your webhook secret is available in SettingsWebhooks → click your endpoint → Signing Secret.


Retry Policy

If your endpoint fails to respond with 2xx, we retry automatically:

AttemptDelay
1Immediate
25 minutes
330 minutes
42 hours
58 hours
624 hours

After 6 failed attempts, the webhook is marked as failed.

Tip: Use the delivery_attempt field in the payload to track retries.


Handle Duplicates

Webhooks may be delivered more than once. Use the event id to detect duplicates:

const processedEvents = new Set();

async function processWebhook(event) {
  // Check if already processed
  if (processedEvents.has(event.id)) {
    console.log('Duplicate event, skipping:', event.id);
    return;
  }

  processedEvents.add(event.id);

  // For production, use a database
  // await db.query('INSERT INTO processed_events (event_id) VALUES (?)', [event.id]);

  // Process the event...
}

Testing Webhooks

Local Development with ngrok

  1. Install ngrok
  2. Start your local server: npm run dev
  3. Expose it: ngrok http 3000
  4. Use the ngrok URL as your webhook endpoint
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000

# Use this as your webhook URL:
# https://abc123.ngrok.io/webhooks/sendpaylinks

Send Test Events

  1. Go to SettingsWebhooks
  2. Click your endpoint
  3. Click Send Test Event
  4. Select an event type
  5. Check your server logs

Complete Example

Here's a production-ready webhook handler:

import express from 'express';
import crypto from 'crypto';

const app = express();

// Store processed events (use Redis/database in production)
const processedEvents = new Map();

app.post('/webhooks/sendpaylinks',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const payload = req.body.toString();

    // 1. Verify signature
    const expected = crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(payload)
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(signature || ''), Buffer.from(expected))) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(payload);

    // 2. Check for duplicates
    if (processedEvents.has(event.id)) {
      return res.status(200).send('Already processed');
    }
    processedEvents.set(event.id, Date.now());

    // 3. Respond immediately
    res.status(200).send('OK');

    // 4. Process async
    try {
      const order = event.data.object;

      switch (event.type) {
        case 'payment.succeeded':
          console.log(`✅ Order ${order.order_id} paid: $${(order.amount.total / 100).toFixed(2)}`);

          // Fulfill the order
          await fulfillOrder({
            orderId: order.order_id,
            customer: order.customer,
            items: order.line_items,
            shippingAddress: order.shipping_address,
          });

          // Send confirmation email
          await sendEmail({
            to: order.customer.email,
            subject: 'Order Confirmed!',
            template: 'order-confirmation',
            data: order,
          });
          break;

        case 'upsell.accepted':
          console.log(`🎉 Upsell accepted for order ${order.order_id}`);
          await addUpsellToOrder(order);
          break;

        case 'payment.failed':
          console.log(`❌ Payment failed for ${order.customer.email}`);
          await sendEmail({
            to: order.customer.email,
            subject: 'Payment Issue',
            template: 'payment-failed',
          });
          break;
      }
    } catch (error) {
      console.error('Webhook processing error:', error);
      // Don't return error - we already sent 200 OK
    }
  }
);

app.listen(3000, () => console.log('Webhook server running on port 3000'));

Troubleshooting

Webhook Not Received

  1. Check your endpoint URL is correct and uses HTTPS
  2. Verify your server is publicly accessible
  3. Check firewall rules allow incoming POST requests
  4. Review webhook logs in SettingsWebhooksDelivery Logs

Invalid Signature Errors

  1. Ensure you're using the raw request body (not parsed JSON) for verification
  2. Check your webhook secret matches the one in the dashboard
  3. Make sure no middleware modifies the request body

Timeouts

  1. Respond with 200 OK immediately
  2. Process webhook data asynchronously
  3. Keep processing under 30 seconds

Best Practices

Always verify signatures in production ✅ Respond quickly with 200, then process async ✅ Handle duplicates using the event ID ✅ Use HTTPS endpoints only ✅ Log everything for debugging ✅ Monitor failures in the dashboard


Next Steps

On this page