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 │
│ │◀───────────────────────│- A customer completes an action (e.g., payment)
- Send Pay Links creates an event
- We POST the event data to your webhook URL
- 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
- Go to Settings → Webhooks
- Click Add Endpoint
- Enter your URL:
https://yoursite.com/webhooks/sendpaylinks - Select which events to receive
- 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
| Event | When It Fires |
|---|---|
checkout.initialized | Checkout page loaded |
checkout.provider_selected | Customer selected payment method |
payment.succeeded | Payment completed successfully |
payment.failed | Payment was declined or failed |
order.confirmed | Order finalized by payment provider |
upsell.accepted | Customer accepted an upsell |
upsell.declined | Customer 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
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature |
X-Webhook-Timestamp | Unix timestamp when sent |
X-Webhook-Id | Unique webhook delivery ID |
X-Webhook-Event | Event type (e.g., payment.succeeded) |
X-Webhook-Delivery-Attempt | Attempt 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 Settings → Webhooks → click your endpoint → Signing Secret.
Retry Policy
If your endpoint fails to respond with 2xx, we retry automatically:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
| 6 | 24 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
- Install ngrok
- Start your local server:
npm run dev - Expose it:
ngrok http 3000 - 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/sendpaylinksSend Test Events
- Go to Settings → Webhooks
- Click your endpoint
- Click Send Test Event
- Select an event type
- 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
- Check your endpoint URL is correct and uses HTTPS
- Verify your server is publicly accessible
- Check firewall rules allow incoming POST requests
- Review webhook logs in Settings → Webhooks → Delivery Logs
Invalid Signature Errors
- Ensure you're using the raw request body (not parsed JSON) for verification
- Check your webhook secret matches the one in the dashboard
- Make sure no middleware modifies the request body
Timeouts
- Respond with
200 OKimmediately - Process webhook data asynchronously
- 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
- API Reference: Webhooks — Full webhook API documentation
- Upsells Guide — Handle upsell webhook events
- External Integration — Complete checkout integration