Skip to main content

Why Webhooks?

Get real-time notifications when payments complete, fail, or change status. Webhooks keep your system in sync without polling.

Instant Updates

Know immediately when payments complete or fail

Reliable Delivery

Automatic retries ensure you never miss events

Efficient

No need to constantly poll for status updates

Complete Information

Each webhook includes full payment details

Setup

1

Create an Endpoint

Create a route on your server to receive webhook POST requests.
2

Register Your URL

Contact our support team to register your webhook endpoint.
3

Verify Signatures

Always validate that webhooks come from Cheqpay (see security below).
Contact support@cheqpay.com to register your webhook endpoint URL.

Webhook Events

Payment Events

EventTriggered WhenRecommended Action
payment.auth.pendingAuthorization is pendingShow pending status to customer
payment.auth.successAuthorization successfulConfirm payment is authorized
payment.auth.failedAuthorization failedNotify customer, offer retry
payment.capture.successPayment captured successfullyFulfill order
payment.capture.failedPayment capture failedInvestigate and notify customer
payment.void.successPayment voided successfullyCancel order, update records
payment.void.failedPayment void failedInvestigate and retry
payment.refund.pendingRefund is being processedShow refund pending status
payment.refund.successRefund processed successfullyConfirm refund to customer
payment.refund.failedRefund failedInvestigate and retry refund

Subscription Events

EventTriggered WhenRecommended Action
subscription.plan_changedSubscription plan changedUpdate user access, log analytics

Webhook Format

Payment Events

Each webhook includes event details and payment information:
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "payment.capture.success",
  "data": {
    "paymentOrder": {
      "id": "ord_xyz789",
      "merchantReference": "ref-12345",
      "externalId": "order-12345"
    },
    "customer": {
      "id": "cus_ghi012",
      "externalId": "customer-456"
    },
    "paymentMethod": {
      "type": "card",
      "options": {
        "card": {
          "id": "card_abc123"
        }
      }
    },
    "amount": "10000",
    "currency": "MXN",
    "createdAt": "2025-10-30T14:30:00.000Z"
  }
}
For SPEI payments, the paymentMethod field looks like this:
{
  "paymentMethod": {
    "type": "spei",
    "options": {
      "clabe": "123456789012345678"
    }
  }
}

Common Fields

FieldTypeDescription
idstringUnique event identifier (UUID)
eventstringType of event (e.g. payment.capture.success)
dataobjectEvent-specific data
data.paymentOrder.idstringCheqpay payment order ID
data.paymentOrder.merchantReferencestringYour merchant reference
data.paymentOrder.externalIdstringYour external order ID
data.customer.idstringCheqpay customer ID
data.customer.externalIdstringYour external customer ID
data.paymentMethod.typestringPayment method type (card or spei)
data.amountstringPayment amount
data.currencystringCurrency code (e.g. MXN)
data.createdAtstringISO 8601 timestamp of payment creation

Handle Webhooks Properly

Requirements

Return 200 OK within 5 seconds. Process data asynchronously.
Always check authenticity before processing events.
Queue work for background processing. Don’t block the response.
Events may be sent multiple times. Make your handler idempotent.

Example Handler (Node.js)

const express = require('express');
const crypto = require('crypto');

app.post('/webhooks/cheqpay', express.json(), (req, res) => {
  // 1. Verify signature first
  const signature = req.headers['x-webhook-signature'];
  if (!verifySignature(req.body, signature)) {
    return res.status(401).send('Invalid signature');
  }

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

  // 3. Process in background
  processWebhook(req.body);
});

function processWebhook(webhook) {
  // Queue for async processing
  queue.add('process-payment', {
    id: webhook.id,
    event: webhook.event,
    data: webhook.data
  });
}

Example Handler (Python)

from flask import Flask, request
import hmac
import hashlib

app = Flask(__name__)

@app.route('/webhooks/cheqpay', methods=['POST'])
def webhook():
    # 1. Verify signature
    signature = request.headers.get('X-Webhook-Signature')
    if not verify_signature(request.json, signature):
        return 'Invalid signature', 401

    # 2. Respond immediately
    # 3. Process async
    webhook_data = request.json
    queue.enqueue(process_webhook, webhook_data)

    return 'OK', 200

def process_webhook(data):
    event = data['event']

    if event == 'payment.capture.success':
        fulfill_order(data['data']['paymentOrder']['externalId'])
    elif event == 'payment.auth.failed':
        notify_customer(data['data'])
    # ... handle other events

Security

Verify every webhook to ensure it’s from Cheqpay.

How Signatures Work

Cheqpay signs webhooks using HMAC-SHA256 with specific payload fields joined by a pipe (|) delimiter. The signature is sent in the x-webhook-signature header. For payment events, the signed fields are:
paymentMethodId | amount | currency | event
For subscription events, the signed fields are:
subscriptionId | newPlanId | changeDirection | event

Signature Verification (Node.js)

const crypto = require('crypto');

function verifyPaymentSignature(payload, signature) {
  const { data } = payload;

  // Get the payment method ID based on type
  const paymentMethodId = data.paymentMethod.type === 'card'
    ? data.paymentMethod.options.card.id
    : data.paymentMethod.options.clabe;

  const parts = [
    paymentMethodId,
    data.amount,
    data.currency,
    payload.event
  ].join('|');

  const expected = crypto
    .createHmac('sha256', YOUR_WEBHOOK_SECRET)
    .update(parts)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Signature Verification (Python)

import hmac
import hashlib

def verify_payment_signature(payload, signature):
    data = payload['data']

    # Get the payment method ID based on type
    pm = data['paymentMethod']
    if pm['type'] == 'card':
        payment_method_id = pm['options']['card']['id']
    else:
        payment_method_id = pm['options']['clabe']

    parts = '|'.join([
        payment_method_id,
        data['amount'],
        data['currency'],
        payload['event']
    ])

    expected = hmac.new(
        YOUR_WEBHOOK_SECRET.encode(),
        parts.encode(),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)
Your webhook secret is provided when you register your endpoint. Keep it secure and never commit it to version control.

Handle Duplicate Events

Cheqpay uses payload hashing to prevent duplicate events, but your handler should still be idempotent:
async function processPaymentEvent(webhook) {
  const eventId = webhook.id;

  // Check if already processed
  const existing = await db.webhookEvents.findOne({ eventId });
  if (existing) {
    console.log('Event already processed:', eventId);
    return;
  }

  // Process the event
  await fulfillOrder(webhook.data.paymentOrder.externalId);

  // Mark as processed
  await db.webhookEvents.insert({
    eventId,
    processedAt: new Date()
  });
}

Event-Specific Handling

Payment Captured

if (webhook.event === 'payment.capture.success') {
  const { paymentOrder, amount, customer } = webhook.data;

  // Fulfill the order
  await orders.fulfill(paymentOrder.externalId);

  // Send confirmation email
  await sendEmail(customer.id, {
    subject: 'Payment Received',
    template: 'payment-confirmation'
  });

  // Update inventory
  await inventory.reduce(paymentOrder.externalId);
}

Payment Authorization Failed

if (webhook.event === 'payment.auth.failed') {
  const { paymentOrder, customer } = webhook.data;

  // Notify customer
  await sendEmail(customer.id, {
    subject: 'Payment Failed',
    template: 'payment-failed',
    data: { orderId: paymentOrder.externalId }
  });

  // Update order status
  await orders.update(paymentOrder.externalId, { status: 'payment_failed' });
}

Refund Processed

if (webhook.event === 'payment.refund.success') {
  const { paymentOrder, amount, customer } = webhook.data;

  // Update order status
  await orders.update(paymentOrder.externalId, { status: 'refunded' });

  // Notify customer
  await sendEmail(customer.id, {
    subject: 'Refund Processed',
    template: 'refund-confirmation',
    data: { amount }
  });
}

Subscription Plan Changed

Triggered when a customer changes their subscription plan. Includes complete proration details and whether the change was an upgrade or downgrade.

Payload Structure

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "subscription.plan_changed",
  "data": {
    "subscriptionId": "sub_abc123def456",
    "customerId": "cus_customer789",
    "previousPlan": {
      "id": "plan_basic_monthly",
      "name": "Basic Plan",
      "amount": "9999"
    },
    "newPlan": {
      "id": "plan_pro_monthly",
      "name": "Pro Plan",
      "amount": "19999"
    },
    "changeDirection": "upgrade",
    "proration": {
      "behavior": "always_invoice",
      "netAmount": "5000",
      "appliedOn": "immediate",
      "invoiceId": "inv_xyz789",
      "chargedAmount": "5000",
      "chargedAt": "2026-01-30T10:30:00.000Z",
      "nextInvoiceDate": "2026-02-15T00:00:00.000Z",
      "nextInvoiceAmount": "19999",
      "billingCycleReset": false
    },
    "changedAt": "2026-01-30T10:30:00.000Z"
  }
}

Payload Fields

FieldTypeDescription
subscriptionIdstringSubscription identifier
customerIdstringCustomer who owns the subscription
previousPlanobjectPlan before the change (id, name, amount)
newPlanobjectPlan after the change (id, name, amount)
changeDirectionstringupgrade, downgrade, or same
prorationobject | nullProration details (null if behavior was none)
proration.behaviorstringProration behavior used: create_prorations, always_invoice, or none
proration.netAmountstring | nullNet proration amount (positive = charge, negative = credit)
proration.appliedOnstring | nullWhen proration applied: immediate, next_invoice, or deferred
proration.invoiceIdstringInvoice ID if immediate charge was created
proration.chargedAmountstringAmount charged immediately (if appliedOn is immediate)
proration.chargedAtstringISO 8601 timestamp of charge
proration.nextInvoiceDatestringDate of next invoice
proration.nextInvoiceAmountstringExpected amount of next invoice
proration.billingCycleResetbooleanWhether billing cycle was reset
changedAtstringISO 8601 timestamp of plan change

Subscription Signature Verification

function verifySubscriptionSignature(payload, signature) {
  const { data } = payload;

  const parts = [
    data.subscriptionId,
    data.newPlan.id,
    data.changeDirection,
    payload.event
  ].join('|');

  const expected = crypto
    .createHmac('sha256', YOUR_WEBHOOK_SECRET)
    .update(parts)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Example Handler

if (webhook.event === 'subscription.plan_changed') {
  const { subscriptionId, customerId, newPlan, changeDirection, proration } = webhook.data;

  // Update user's plan access
  await users.updatePlan(customerId, {
    planId: newPlan.id,
    features: getFeatures(newPlan.id)
  });

  // Log the change for analytics
  await analytics.track('plan_changed', {
    customerId,
    direction: changeDirection,
    fromPlan: webhook.data.previousPlan.name,
    toPlan: newPlan.name,
    immediateCharge: proration?.appliedOn === 'immediate'
  });

  // Send email based on change type
  if (changeDirection === 'upgrade') {
    await sendEmail(customerId, {
      subject: 'Welcome to ' + newPlan.name,
      template: 'plan-upgrade',
      data: {
        newPlan: newPlan.name,
        chargedAmount: proration?.chargedAmount,
        nextInvoiceDate: proration?.nextInvoiceDate
      }
    });
  } else if (changeDirection === 'downgrade') {
    await sendEmail(customerId, {
      subject: 'Plan Changed to ' + newPlan.name,
      template: 'plan-downgrade',
      data: {
        newPlan: newPlan.name,
        creditAmount: Math.abs(proration?.netAmount || 0),
        nextInvoiceDate: proration?.nextInvoiceDate
      }
    });
  }
}
Proration can be null if the merchant used prorationBehavior: "none" when changing the plan. Always check if proration exists before accessing its properties.
Use the changeDirection field to simplify your logic. You don’t need to compare plan amounts manually.

Automatic Retries

If your endpoint is unavailable, Cheqpay retries automatically with up to 10 attempts.

Test Mode (Sandbox)

Retries every 60 minutes for up to 10 attempts.

Live Mode (Production)

1

Initial Attempt

Webhook is sent to your endpoint.
2

Retries 1-4

Every 5 minutes (at 5, 10, 15, and 20 minutes after initial attempt).
3

Retries 5-10

Every 60 minutes until max retries are exhausted.
4

Failed

After all retries are exhausted, the webhook is marked as failed.
Set up monitoring to alert you of webhook failures before all retries are exhausted.

Testing Locally

Use ngrok to test webhooks during development:
# Install ngrok
npm install -g ngrok

# Start your local server
node server.js  # Running on port 3000

# Expose to internet
ngrok http 3000

# Output:
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000
Register the ngrok URL with Cheqpay:
https://abc123.ngrok.io/webhooks/cheqpay
Now you can test webhooks with your local development server!
Remember to update to your production URL before going live.

Monitoring and Debugging

Log All Webhooks

app.post('/webhooks/cheqpay', (req, res) => {
  // Log webhook for debugging
  logger.info('Webhook received', {
    id: req.body.id,
    event: req.body.event,
    signature: req.headers['x-webhook-signature']
  });

  // ... rest of handler
});

Monitor Webhook Health

Track webhook delivery and processing:
const metrics = {
  received: 0,
  processed: 0,
  failed: 0,
  averageProcessingTime: 0
};

async function processWebhook(webhook) {
  metrics.received++;
  const start = Date.now();

  try {
    await handleWebhook(webhook);
    metrics.processed++;
  } catch (error) {
    metrics.failed++;
    logger.error('Webhook processing failed', { error, webhook });
  }

  const duration = Date.now() - start;
  metrics.averageProcessingTime =
    (metrics.averageProcessingTime + duration) / 2;
}

Best Practices

Always use HTTPS endpoints for production. Cheqpay will not send webhooks to HTTP URLs in live mode.
Never skip signature verification. This protects against spoofed webhooks.
Respond within 5 seconds. Use background jobs for actual processing.
Use the webhook id to track processed events and avoid duplicate processing.
Set up alerts for webhook failures. Investigate and fix issues quickly.
Log all webhook events for debugging and audit trails.

Webhook Endpoint Requirements

HTTPS Required

Must use secure HTTPS connection in production

Public Access

Endpoint must be publicly accessible

Fast Response

Return 200 OK within 5 seconds

Signature Verification

Verify signatures on all requests

Next Steps