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
Create an Endpoint
Create a route on your server to receive webhook POST requests.
Register Your URL
Contact our support team to register your webhook endpoint.
Verify Signatures
Always validate that webhooks come from Cheqpay (see security below).
Webhook Events
Payment Events
Event Triggered When Recommended Action payment.auth.pendingAuthorization is pending Show pending status to customer payment.auth.successAuthorization successful Confirm payment is authorized payment.auth.failedAuthorization failed Notify customer, offer retry payment.capture.successPayment captured successfully Fulfill order payment.capture.failedPayment capture failed Investigate and notify customer payment.void.successPayment voided successfully Cancel order, update records payment.void.failedPayment void failed Investigate and retry payment.refund.pendingRefund is being processed Show refund pending status payment.refund.successRefund processed successfully Confirm refund to customer payment.refund.failedRefund failed Investigate and retry refund
Subscription Events
Event Triggered When Recommended Action subscription.plan_changedSubscription plan changed Update user access, log analytics
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
Field Type Description idstring Unique event identifier (UUID) eventstring Type of event (e.g. payment.capture.success) dataobject Event-specific data data.paymentOrder.idstring Cheqpay payment order ID data.paymentOrder.merchantReferencestring Your merchant reference data.paymentOrder.externalIdstring Your external order ID data.customer.idstring Cheqpay customer ID data.customer.externalIdstring Your external customer ID data.paymentMethod.typestring Payment method type (card or spei) data.amountstring Payment amount data.currencystring Currency code (e.g. MXN) data.createdAtstring ISO 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
Field Type Description subscriptionIdstring Subscription identifier customerIdstring Customer who owns the subscription previousPlanobject Plan before the change (id, name, amount) newPlanobject Plan after the change (id, name, amount) changeDirectionstring upgrade, downgrade, or sameprorationobject | null Proration details (null if behavior was none) proration.behaviorstring Proration behavior used: create_prorations, always_invoice, or none proration.netAmountstring | null Net proration amount (positive = charge, negative = credit) proration.appliedOnstring | null When proration applied: immediate, next_invoice, or deferred proration.invoiceIdstring Invoice ID if immediate charge was created proration.chargedAmountstring Amount charged immediately (if appliedOn is immediate) proration.chargedAtstring ISO 8601 timestamp of charge proration.nextInvoiceDatestring Date of next invoice proration.nextInvoiceAmountstring Expected amount of next invoice proration.billingCycleResetboolean Whether billing cycle was reset changedAtstring ISO 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)
Initial Attempt
Webhook is sent to your endpoint.
Retries 1-4
Every 5 minutes (at 5, 10, 15, and 20 minutes after initial attempt).
Retries 5-10
Every 60 minutes until max retries are exhausted.
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