Send Pay Links
API Reference

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:

EventWhen It FiresPurpose
checkout.initializedCheckout page loadsCustomer begins checkout flow
checkout.provider_selectedCustomer selects payment methodPayment provider chosen
payment.initiatedPayment processing startsFunds being collected
payment.succeededPayment completes successfullyTransaction approved
payment.failedPayment is declined or errorsTransaction rejected/failed
order.createdOrder record createdOrder entered the system
order.confirmedOrder confirmed and finalizedPayment provider confirmed order
upsell.offeredUpsell offer displayedCustomer shown additional offer
upsell.acceptedCustomer accepts upsellAdditional purchase made
upsell.declinedCustomer declines upsellOffer rejected
upsell.expiredUpsell session expiresOffer window closed
refund.initiatedRefund processing startsRefund requested
refund.succeededRefund completesFunds returned
refund.failedRefund failsRefund 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

FieldTypeDescription
idstringUnique event ID (format: evt_<timestamp>_<random>)
typestringEvent type (one of the event types listed above)
creatednumberUnix timestamp when event was created
livemodebooleantrue in production, false in test mode
api_versionstringAPI version (e.g., 2024-12-01)
requestobjectRequest metadata for tracing
dataobjectEvent data object (see below)
webhook_idstringUnique webhook delivery ID
delivery_attemptnumberDelivery attempt number (1, 2, 3...)
signature_versionstringSignature algorithm version (v1)
delivered_atstringISO 8601 timestamp when delivered

Request Object

FieldTypeDescription
idstringRequest ID for tracing (format: req_<uuid>)
idempotency_keystringIdempotency key (same as event ID)

Data Object

The data.object contains the full checkout/order context:

FieldTypeDescription
objectstringObject type: checkout, order, payment, or upsell
idstringInternal checkout/order ID
order_idstringOrder identifier
provider_order_idstringPayment provider's order ID
organization_idstringOrganization (tenant) ID
brandobjectBrand information (id, name)
paymentobjectPayment details (provider, transaction ID, status, errors)
amountobjectPricing breakdown (subtotal, shipping, tax, discount, total, currency)
customerobjectCustomer info (email, name, phone, IP address)
billing_addressobjectBilling address details
shipping_addressobjectShipping address details
line_itemsarrayArray of line items purchased
upsellsarrayArray of upsell offers (if any)
sales_attributionobjectSales agent/commission information (if applicable)
created_atnumberUnix timestamp when created
updated_atnumberUnix timestamp when last updated
completed_atnumberUnix timestamp when completed
metadataobjectCustom metadata from checkout token
order_metadataobjectOrder-level metadata for data warehouse
provider_dataobjectProvider-specific data

Amount Field (All in Cents)

Amounts are always in cents. For example, 4999 = $49.99 USD.

FieldTypeDescription
subtotalnumberPre-tax, pre-shipping subtotal in cents
shippingnumberShipping cost in cents
taxnumberTax amount in cents
discountnumberDiscount/coupon amount in cents
totalnumberFinal total in cents
currencystringISO 4217 currency code (e.g., USD, EUR)

Payment Field

FieldTypeDescription
providerstringPayment provider: stripe, sticky, shopify, konnektive, ultracart, nmi, epd
transaction_idstringProvider transaction ID
payment_method_tokenstringStored payment method token for 1-click upsells
statusstringPayment status: pending, processing, succeeded, failed, refunded, cancelled
error_messagestringError message if payment failed
error_codestringError code if payment failed

Line Item Object

FieldTypeDescription
idstringLine item ID
product_idstringProduct identifier
namestringProduct name
descriptionstringProduct description
skustringSKU (stock keeping unit)
quantitynumberQuantity purchased
unit_pricenumberUnit price in cents
total_pricenumberTotal line item price in cents
image_urlstringProduct image URL
metadataobjectProvider-specific metadata (variantId, campaignProductId, etc.)

Upsell Object

FieldTypeDescription
idstringUpsell ID
offer_idstringOffer identifier
product_idstringProduct ID
namestringOffer name
descriptionstringOffer description
skustringSKU
statusstringaccepted, declined, or pending
pricenumberOffer price in cents
original_pricenumberCompare-at price in cents
image_urlstringOffer image URL
transaction_idstringPayment transaction ID (if accepted)
accepted_atnumberUnix timestamp when accepted
metadataobjectOffer metadata

Sales Attribution Object

Present when the checkout was generated by a sales agent:

FieldTypeDescription
agent_idstringSales agent ID
agent_namestringAgent name
agent_emailstringAgent email
organization_idstringOrganization ID
team_idstringTeam/department ID
team_namestringTeam name
commission_ratenumberCommission rate (e.g., 0.10 for 10%)
commission_typestringpercentage, flat, or tiered
sourcestringSource: phone_sale, chat, email, in_person
campaign_codestringCampaign or coupon code
generated_atnumberUnix timestamp when link was generated

Webhook Headers

Each webhook request includes these headers for verification and tracing:

HeaderDescriptionExample
Content-TypeMIME typeapplication/json
X-Request-IDUnique request ID for tracingreq_abc123def456
X-Webhook-EventEvent typepayment.succeeded
X-Webhook-IdUnique webhook delivery IDwh_1706745650_xyz789
X-Webhook-TimestampUnix timestamp of event1706745600
X-Webhook-Delivery-AttemptAttempt number (1, 2, 3...)1
X-Webhook-API-VersionAPI version2024-12-01
X-Webhook-SignatureHMAC-SHA256 signaturesha256=abc123...
X-Webhook-Signature-VersionSignature algorithm versionv1

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:

SettingMinMaxDefault
Max Retries0103
Initial Delay (ms)10060,0001,000
Timeout (ms)1,00060,00010,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

  1. Go to your admin dashboard
  2. Navigate to Settings > Webhooks
  3. Click Add Endpoint
  4. Enter your webhook URL (must be HTTPS in production)
  5. Select which events to receive (or leave as "All Events")
  6. Optionally set a custom signing secret
  7. 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_secret

Best Practices

Respond Quickly: Return a 2xx status code within 30 seconds. Use background jobs to process webhook data asynchronously.

  1. Verify Signatures — Always verify the webhook signature before processing any data
  2. Handle Duplicates — Use the id field to detect and handle duplicate deliveries (events may be delivered multiple times)
  3. Respond Immediately — Return a 2xx status quickly, then process the webhook asynchronously
  4. Use HTTPS — Always use HTTPS endpoints; HTTP will be rejected in production
  5. Log Everything — Log all webhook requests and responses for debugging
  6. Idempotent Processing — Make your webhook handlers idempotent (safe to process the same event multiple times)
  7. 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

  1. Go to Settings > Webhooks
  2. Click the menu on your endpoint and select Test Webhook
  3. Choose an event type to send as a test
  4. Click Send Test Event
  5. 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/checkout

Manual 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=true

This 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 timingSafeEqual to 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 id field to detect duplicates
  • Use a database table or cache to track processed events
  • Implement idempotent business logic

On this page