Webhooks
Receive real-time notifications for checkout, payment, order, upsell, and refund events
Webhooks
Webhooks deliver real-time event notifications to your endpoint when checkout, payment, and order events occur. Each webhook is signed with HMAC-SHA256 and includes comprehensive event data for seamless integration.
Event Types
The platform emits the following event types:
| Event | When It Fires | Purpose |
|---|---|---|
checkout.initialized | Checkout page loads | Customer begins checkout flow |
checkout.provider_selected | Customer selects payment method | Payment provider chosen |
payment.initiated | Payment processing starts | Funds being collected |
payment.succeeded | Payment completes successfully | Transaction approved |
payment.failed | Payment is declined or errors | Transaction rejected/failed |
order.created | Order record created | Order entered the system |
order.confirmed | Order confirmed and finalized | Payment provider confirmed order |
upsell.offered | Upsell offer displayed | Customer shown additional offer |
upsell.accepted | Customer accepts upsell | Additional purchase made |
upsell.declined | Customer declines upsell | Offer rejected |
upsell.expired | Upsell session expires | Offer window closed |
refund.initiated | Refund processing starts | Refund requested |
refund.succeeded | Refund completes | Funds returned |
refund.failed | Refund fails | Refund processing error |
Webhook Payload Structure
All webhooks follow a standard structure with comprehensive event data. Here's a payment.succeeded example:
{
"id": "evt_1706745600_abc123",
"type": "payment.succeeded",
"created": 1706745600,
"livemode": true,
"api_version": "2024-12-01",
"request": {
"id": "req_abc123def456",
"idempotency_key": "evt_1706745600_abc123"
},
"data": {
"object": {
"object": "payment",
"id": "chk_123456",
"order_id": "order_789",
"provider_order_id": "pi_stripe_abc123",
"organization_id": "org_456",
"brand": {
"id": "brand_123",
"name": "My Store"
},
"payment": {
"provider": "stripe",
"transaction_id": "pi_stripe_abc123",
"payment_method_token": "pm_123|cus_456",
"status": "succeeded",
"error_message": null,
"error_code": null
},
"amount": {
"subtotal": 4999,
"shipping": 499,
"tax": 440,
"discount": 0,
"total": 5938,
"currency": "USD"
},
"customer": {
"id": null,
"email": "john@example.com",
"first_name": "John",
"last_name": "Doe",
"phone": "+1234567890",
"ip_address": "203.0.113.1"
},
"billing_address": {
"line1": "123 Main St",
"line2": "Apt 4B",
"city": "New York",
"state": "NY",
"postal_code": "10001",
"country": "US"
},
"shipping_address": {
"line1": "123 Main St",
"line2": "Apt 4B",
"city": "New York",
"state": "NY",
"postal_code": "10001",
"country": "US"
},
"line_items": [
{
"id": "item_001",
"product_id": "item_001",
"name": "Premium Widget",
"description": "High-quality premium widget",
"sku": "WIDGET-PRO-001",
"quantity": 2,
"unit_price": 2499,
"total_price": 4998,
"image_url": "https://example.com/widget.jpg",
"metadata": {
"variantId": "var_123",
"campaignProductId": "cp_456"
}
}
],
"upsells": [
{
"id": "ups_001",
"offer_id": "offer_abc",
"product_id": "prod_xyz",
"name": "Extended Warranty",
"description": "2-year extended warranty",
"sku": "WARRANTY-2YR",
"status": "accepted",
"price": 999,
"original_price": 1999,
"image_url": "https://example.com/warranty.jpg",
"transaction_id": "pi_upsell_123",
"accepted_at": 1706745650,
"metadata": {
"offerId": "sticky_offer_123"
}
}
],
"sales_attribution": {
"agent_id": "agent_789",
"agent_name": "Jane Smith",
"agent_email": "jane@company.com",
"organization_id": "org_456",
"team_id": "team_sales",
"team_name": "Inside Sales",
"commission_rate": 0.10,
"commission_type": "percentage",
"source": "phone_sale",
"campaign_code": "SUMMER2024",
"generated_at": 1706745000
},
"created_at": 1706745600,
"updated_at": 1706745600,
"completed_at": 1706745650,
"metadata": {
"campaign": "summer_sale",
"source": "landing_page"
},
"order_metadata": {
"warehouse_id": "WH-001",
"fulfillment_priority": "high"
},
"provider_data": {}
},
"previous_attributes": null
},
"webhook_id": "wh_1706745650_xyz789",
"delivery_attempt": 1,
"signature_version": "v1",
"delivered_at": "2024-01-31T12:00:50.000Z"
}Payload Field Reference
Top-Level Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID (format: evt_<timestamp>_<random>) |
type | string | Event type (one of the event types listed above) |
created | number | Unix timestamp when event was created |
livemode | boolean | true in production, false in test mode |
api_version | string | API version (e.g., 2024-12-01) |
request | object | Request metadata for tracing |
data | object | Event data object (see below) |
webhook_id | string | Unique webhook delivery ID |
delivery_attempt | number | Delivery attempt number (1, 2, 3...) |
signature_version | string | Signature algorithm version (v1) |
delivered_at | string | ISO 8601 timestamp when delivered |
Request Object
| Field | Type | Description |
|---|---|---|
id | string | Request ID for tracing (format: req_<uuid>) |
idempotency_key | string | Idempotency key (same as event ID) |
Data Object
The data.object contains the full checkout/order context:
| Field | Type | Description |
|---|---|---|
object | string | Object type: checkout, order, payment, or upsell |
id | string | Internal checkout/order ID |
order_id | string | Order identifier |
provider_order_id | string | Payment provider's order ID |
organization_id | string | Organization (tenant) ID |
brand | object | Brand information (id, name) |
payment | object | Payment details (provider, transaction ID, status, errors) |
amount | object | Pricing breakdown (subtotal, shipping, tax, discount, total, currency) |
customer | object | Customer info (email, name, phone, IP address) |
billing_address | object | Billing address details |
shipping_address | object | Shipping address details |
line_items | array | Array of line items purchased |
upsells | array | Array of upsell offers (if any) |
sales_attribution | object | Sales agent/commission information (if applicable) |
created_at | number | Unix timestamp when created |
updated_at | number | Unix timestamp when last updated |
completed_at | number | Unix timestamp when completed |
metadata | object | Custom metadata from checkout token |
order_metadata | object | Order-level metadata for data warehouse |
provider_data | object | Provider-specific data |
Amount Field (All in Cents)
Amounts are always in cents. For example, 4999 = $49.99 USD.
| Field | Type | Description |
|---|---|---|
subtotal | number | Pre-tax, pre-shipping subtotal in cents |
shipping | number | Shipping cost in cents |
tax | number | Tax amount in cents |
discount | number | Discount/coupon amount in cents |
total | number | Final total in cents |
currency | string | ISO 4217 currency code (e.g., USD, EUR) |
Payment Field
| Field | Type | Description |
|---|---|---|
provider | string | Payment provider: stripe, sticky, shopify, konnektive, ultracart, nmi, epd |
transaction_id | string | Provider transaction ID |
payment_method_token | string | Stored payment method token for 1-click upsells |
status | string | Payment status: pending, processing, succeeded, failed, refunded, cancelled |
error_message | string | Error message if payment failed |
error_code | string | Error code if payment failed |
Line Item Object
| Field | Type | Description |
|---|---|---|
id | string | Line item ID |
product_id | string | Product identifier |
name | string | Product name |
description | string | Product description |
sku | string | SKU (stock keeping unit) |
quantity | number | Quantity purchased |
unit_price | number | Unit price in cents |
total_price | number | Total line item price in cents |
image_url | string | Product image URL |
metadata | object | Provider-specific metadata (variantId, campaignProductId, etc.) |
Upsell Object
| Field | Type | Description |
|---|---|---|
id | string | Upsell ID |
offer_id | string | Offer identifier |
product_id | string | Product ID |
name | string | Offer name |
description | string | Offer description |
sku | string | SKU |
status | string | accepted, declined, or pending |
price | number | Offer price in cents |
original_price | number | Compare-at price in cents |
image_url | string | Offer image URL |
transaction_id | string | Payment transaction ID (if accepted) |
accepted_at | number | Unix timestamp when accepted |
metadata | object | Offer metadata |
Sales Attribution Object
Present when the checkout was generated by a sales agent:
| Field | Type | Description |
|---|---|---|
agent_id | string | Sales agent ID |
agent_name | string | Agent name |
agent_email | string | Agent email |
organization_id | string | Organization ID |
team_id | string | Team/department ID |
team_name | string | Team name |
commission_rate | number | Commission rate (e.g., 0.10 for 10%) |
commission_type | string | percentage, flat, or tiered |
source | string | Source: phone_sale, chat, email, in_person |
campaign_code | string | Campaign or coupon code |
generated_at | number | Unix timestamp when link was generated |
Webhook Headers
Each webhook request includes these headers for verification and tracing:
| Header | Description | Example |
|---|---|---|
Content-Type | MIME type | application/json |
X-Request-ID | Unique request ID for tracing | req_abc123def456 |
X-Webhook-Event | Event type | payment.succeeded |
X-Webhook-Id | Unique webhook delivery ID | wh_1706745650_xyz789 |
X-Webhook-Timestamp | Unix timestamp of event | 1706745600 |
X-Webhook-Delivery-Attempt | Attempt number (1, 2, 3...) | 1 |
X-Webhook-API-Version | API version | 2024-12-01 |
X-Webhook-Signature | HMAC-SHA256 signature | sha256=abc123... |
X-Webhook-Signature-Version | Signature algorithm version | v1 |
Verifying Webhook Signatures
Always verify webhook signatures to ensure authenticity. The signature uses HMAC-SHA256 with your webhook signing secret.
Always verify signatures: Failing to verify signatures can allow attackers to impersonate legitimate webhooks. Always validate the signature before processing.
The signature format is: sha256=<hex_digest>
import crypto from 'crypto';
function verifyWebhook(
rawPayload: string,
signatureHeader: string,
secret: string
): boolean {
// Generate expected signature
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawPayload)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expectedSignature)
);
}
// Express example
import express from 'express';
const app = express();
// Important: Use raw body middleware for signature verification
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks/checkout', (req, res) => {
const signature = req.headers['x-webhook-signature'] as string;
const payload = req.body as Buffer;
if (!verifyWebhook(payload.toString(), signature, process.env.WEBHOOK_SECRET!)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// Signature verified, safe to process
const event = JSON.parse(payload.toString());
console.log('Received event:', event.type, event.id);
// Return 2xx immediately
res.status(200).send('OK');
// Process asynchronously
processEvent(event).catch(err => {
console.error('Error processing webhook:', err);
});
});
async function processEvent(event: any) {
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'order.confirmed':
await handleOrderConfirmed(event.data.object);
break;
case 'upsell.accepted':
await handleUpsellAccepted(event.data.object);
break;
// ... handle other event types
}
}Retry Policy
If your endpoint returns a non-2xx response, we'll automatically retry the webhook with exponential backoff.
Default Configuration
- Max Retries: 3 attempts
- Initial Delay: 1000ms
- Backoff Multiplier: 2x per attempt
- Retry Schedule:
- Attempt 1: immediate
- Attempt 2: after 1 second
- Attempt 3: after 2 seconds
- Attempt 4: after 4 seconds
Configurable Settings
Retry behavior can be customized via the admin dashboard:
| Setting | Min | Max | Default |
|---|---|---|---|
| Max Retries | 0 | 10 | 3 |
| Initial Delay (ms) | 100 | 60,000 | 1,000 |
| Timeout (ms) | 1,000 | 60,000 | 10,000 |
Retry Logic
- Retryable: 5xx errors, timeouts, network errors
- Non-retryable: 4xx errors (except 429 rate limiting)
- Rate Limiting: 429 responses are retried
The delivery_attempt field in the webhook payload indicates the current attempt number.
Configuring Webhooks
Dashboard Setup
- Go to your admin dashboard
- Navigate to Settings > Webhooks
- Click Add Endpoint
- Enter your webhook URL (must be HTTPS in production)
- Select which events to receive (or leave as "All Events")
- Optionally set a custom signing secret
- Click Save
Environment Variables
You can also configure webhooks via environment variables:
# Single endpoint
WEBHOOK_URLS=https://api.example.com/webhook
# Multiple endpoints (comma-separated)
WEBHOOK_URLS=https://api.example.com/webhook,https://backup.example.com/webhook
# Per-endpoint event filtering (optional)
WEBHOOK_URL_1_EVENTS=payment.succeeded,order.confirmed
WEBHOOK_URL_2_EVENTS=*
# Per-endpoint secrets (optional)
WEBHOOK_URL_1_SECRET=whsec_endpoint1_secret
WEBHOOK_URL_2_SECRET=whsec_endpoint2_secretBest Practices
Respond Quickly: Return a 2xx status code within 30 seconds. Use background jobs to process webhook data asynchronously.
- Verify Signatures — Always verify the webhook signature before processing any data
- Handle Duplicates — Use the
idfield to detect and handle duplicate deliveries (events may be delivered multiple times) - Respond Immediately — Return a 2xx status quickly, then process the webhook asynchronously
- Use HTTPS — Always use HTTPS endpoints; HTTP will be rejected in production
- Log Everything — Log all webhook requests and responses for debugging
- Idempotent Processing — Make your webhook handlers idempotent (safe to process the same event multiple times)
- Handle All Event Types — Your handler should gracefully handle unknown event types for future compatibility
Complete Example Handler
import express from 'express';
import crypto from 'crypto';
const app = express();
// Use raw body middleware so we can access the original request body for signature verification
app.use(express.raw({ type: 'application/json' }));
// Track processed events to handle duplicates
const processedEvents = new Set<string>();
app.post('/webhooks/checkout', async (req, res) => {
const signature = req.headers['x-webhook-signature'] as string;
const payload = req.body as Buffer;
const rawPayload = payload.toString();
// 1. Verify webhook signature
const secret = process.env.WEBHOOK_SECRET!;
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawPayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Parse the event
let event;
try {
event = JSON.parse(rawPayload);
} catch (err) {
console.error('Failed to parse webhook payload:', err);
return res.status(400).json({ error: 'Invalid JSON' });
}
// 3. Check for duplicates
if (processedEvents.has(event.id)) {
console.log('Duplicate webhook received, already processed:', event.id);
return res.status(200).json({ message: 'Already processed' });
}
processedEvents.add(event.id);
// 4. Respond immediately with 200 OK
res.status(200).json({ message: 'Received' });
// 5. Process the webhook asynchronously
handleWebhook(event).catch(err => {
console.error('Error processing webhook:', err);
// Could implement retry logic, Sentry reporting, etc.
});
});
async function handleWebhook(event: any) {
const { type, data } = event;
const object = data.object;
console.log('Processing webhook:', {
id: event.id,
type: event.type,
orderId: object.order_id,
amount: object.amount.total,
currency: object.amount.currency,
});
try {
switch (type) {
case 'payment.succeeded':
await handlePaymentSuccess(object);
break;
case 'payment.failed':
await handlePaymentFailed(object);
break;
case 'order.confirmed':
await handleOrderConfirmed(object);
break;
case 'upsell.accepted':
await handleUpsellAccepted(object);
break;
case 'upsell.declined':
await handleUpsellDeclined(object);
break;
case 'refund.succeeded':
await handleRefundSucceeded(object);
break;
default:
// Gracefully handle unknown event types
console.warn('Unknown event type:', type);
}
} catch (error) {
console.error('Error handling event:', error);
throw error; // Could implement retry queue here
}
}
async function handlePaymentSuccess(object: any) {
const { order_id, amount, customer, line_items } = object;
console.log('Payment succeeded:', {
orderId: order_id,
amount: (amount.total / 100).toFixed(2),
currency: amount.currency,
customer: customer.email,
});
// Create order in your system
// Send confirmation email
// Trigger fulfillment
// etc.
}
async function handlePaymentFailed(object: any) {
const { order_id, customer, payment } = object;
console.log('Payment failed:', {
orderId: order_id,
customer: customer.email,
error: payment.error_message,
});
// Send failure email
// Log for review
}
async function handleOrderConfirmed(object: any) {
const { order_id } = object;
console.log('Order confirmed:', order_id);
// Update order status
// Process shipment
}
async function handleUpsellAccepted(object: any) {
const { order_id, upsells } = object;
console.log('Upsell accepted:', {
orderId: order_id,
upsells: upsells.length,
});
// Charge additional amount
// Update order with upsell products
}
async function handleUpsellDeclined(object: any) {
const { order_id } = object;
console.log('Upsell declined:', order_id);
// Log decline
// Could use for analytics
}
async function handleRefundSucceeded(object: any) {
const { order_id, amount } = object;
console.log('Refund succeeded:', {
orderId: order_id,
refundAmount: (amount.total / 100).toFixed(2),
});
// Update order status to refunded
// Send refund confirmation email
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
});Testing Webhooks
Dashboard Test Tool
- Go to Settings > Webhooks
- Click the menu on your endpoint and select Test Webhook
- Choose an event type to send as a test
- Click Send Test Event
- Check your logs for the received webhook
Local Testing with ngrok
For local development, use ngrok to expose your local server:
# Start ngrok tunnel (exposes localhost:3000 to the internet)
ngrok http 3000
# Output will show:
# Forwarding https://12345.ngrok.io -> http://localhost:3000
# Use the ngrok URL in your webhook configuration
# https://12345.ngrok.io/webhooks/checkoutManual Testing with curl
# Generate signature
SECRET="whsec_your_webhook_secret"
PAYLOAD='{"id":"evt_test_123","type":"payment.succeeded",...}'
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')"
# Send test webhook
curl -X POST http://localhost:3000/webhooks/checkout \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIGNATURE" \
-H "X-Webhook-Event: payment.succeeded" \
-d "$PAYLOAD"Monitoring & Debugging
View Delivery History
In the admin dashboard, click on your webhook endpoint to see:
- Recent deliveries with status codes
- Failed attempts and retry counts
- Request/response timing
- Error messages
Enable Debug Logging
Set the environment variable to enable verbose webhook logging:
WEBHOOK_DEBUG=trueThis will log all webhook requests and responses for debugging.
Common Issues
Signature verification failing:
- Ensure you're using the raw request body, not the parsed JSON
- Verify you're using the correct webhook signing secret
- Check that you're comparing signatures with
timingSafeEqualto prevent timing attacks
Webhooks not being delivered:
- Check that your endpoint URL is publicly accessible
- Verify you're returning a 2xx status code quickly
- Ensure your endpoint is HTTPS in production
- Check the delivery history in the dashboard for error messages
Duplicate processing:
- Always check the
idfield to detect duplicates - Use a database table or cache to track processed events
- Implement idempotent business logic