Webhooks
CatalystPay's outbound webhook system notifies your server when events occur -- payments complete, transactions settle, chargebacks arrive, subscriptions change. This guide covers event types, delivery mechanics, signature verification, and best practices.
Event types
| Event Type | Trigger |
|---|---|
order.created |
Order created via hosted payment page or API submission |
order.imported |
Order created via CSV import |
transaction.created |
Transaction created via hosted payment page or API submission |
transaction.imported |
Transaction created via CSV import |
transaction.status_changed |
Transaction status updated (e.g., settlement or webhook) |
chargeback.created |
Chargeback created via API or webhook |
chargeback.imported |
Chargeback created via CSV import |
subscription.created |
Subscription created via hosted payment page or API submission |
subscription.imported |
Subscription created via CSV import |
subscription.status_changed |
Subscription status updated |
payment_session.completed |
Payment session reached COMPLETED status |
Webhook headers
Every webhook delivery includes these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-CatalystPay-Event |
Event type (e.g., order.created) |
X-CatalystPay-Signature |
HMAC-SHA256 hex digest |
User-Agent |
CatalystPay-Webhook/1.0 |
Delivery methods
Each webhook endpoint supports one of two delivery methods:
| Method | HTTP | Payload Location |
|---|---|---|
JSON_POST (default) |
POST | JSON request body |
URL_PARAMS |
GET | URL query parameters |
Both methods include the same HMAC signature and event type headers.
Signature verification
All webhook deliveries are signed with HMAC-SHA256 using a per-profile signing secret. Verify the signature before processing any webhook data.
How the signature is computed
- Serialize the JSON payload with sorted keys and compact separators:
json.dumps(payload, sort_keys=True, separators=(',', ':')) - Compute HMAC-SHA256 using the webhook profile's signing secret as the key
- The result is a lowercase hex digest in the
X-CatalystPay-Signatureheader
Verification code
import hmac
import hashlib
import json
def verify_webhook(payload_body: bytes, signature: str, signing_secret: str) -> bool:
# Reconstruct canonical JSON (sorted keys, compact separators)
payload_dict = json.loads(payload_body)
canonical = json.dumps(payload_dict, sort_keys=True, separators=(",", ":"))
expected = hmac.new(
signing_secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
# Usage in a Flask/Django view:
signature = request.headers.get("X-CatalystPay-Signature", "")
if not verify_webhook(request.body, signature, SIGNING_SECRET):
return HttpResponse(status=401)
const crypto = require("crypto");
// Recursively sort object keys to match Python's sort_keys=True
function sortKeys(obj) {
if (typeof obj !== "object" || obj === null) return obj;
if (Array.isArray(obj)) return obj.map(sortKeys);
return Object.keys(obj).sort().reduce((acc, key) => {
acc[key] = sortKeys(obj[key]);
return acc;
}, {});
}
function verifyWebhook(payloadBody, signature, signingSecret) {
const payload = JSON.parse(payloadBody);
// Compact JSON with sorted keys -- matches Python's
// json.dumps(payload, sort_keys=True, separators=(',', ':'))
const canonical = JSON.stringify(sortKeys(payload));
const expected = crypto
.createHmac("sha256", signingSecret)
.update(canonical)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
# Compute the expected signature manually:
echo -n '{"amount":"29.99","currency":"EUR","email":"[email protected]","order_number":"ORD-a1b2c3d4","session_token":"ps_...","status":"COMPLETED"}' \
| openssl dgst -sha256 -hmac "your_signing_secret"
The signature is computed over canonical JSON (sorted keys, compact separators). If you re-serialize the payload with different formatting, the signature will not match. Always canonicalize before computing the HMAC.
Retry policy
Failed deliveries are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 24 hours |
Maximum retries: 5. After all retries are exhausted, the delivery is marked as FAILED.
What gets retried
- HTTP 5xx responses -- Server errors are retried
- HTTP 429 -- Rate limit responses are retried
- Network errors -- Connection timeouts, DNS failures
What does NOT get retried
- HTTP 4xx (except 429) -- Client errors like 400, 401, 403, 404 are treated as permanent failures. Retrying the same request will produce the same error.
Circuit breaker
Each webhook endpoint tracks consecutive failures. After 20 consecutive failures, CatalystPay automatically deactivates the endpoint.
A successful delivery (HTTP 2xx) resets the failure counter to zero. Intermittent failures do not trigger the circuit breaker -- only sustained failure.
When the circuit breaker trips, an administrator must manually reactivate the endpoint after investigating the root cause.
Endpoint requirements
Your webhook endpoint must:
- Respond within 15 seconds (request timeout)
- Return an HTTP 2xx status code to indicate successful receipt
- Be publicly accessible over HTTPS
Return a 200 response as quickly as possible. Process the webhook payload asynchronously (e.g., queue it for a background worker) to avoid timeouts.
Idempotency
Webhooks operate under an at-least-once delivery guarantee. The same event may be delivered multiple times due to network timeouts, worker restarts, or retry behavior.
Design your webhook handlers to be idempotent. Use the entity identifiers in the payload (order.id, transaction.id, subscription.id) as idempotency keys. If you have already processed an event for that entity and event type, skip the duplicate.
Payload variables
Payloads are built from a configurable mapping. Each endpoint defines which variables to include. Common payload variables per event type:
payment_session.completed
| Variable | Type | Description |
|---|---|---|
session.token |
string | Session token |
session.status |
string | Session status |
order.id |
uuid | Order ID |
order.order_number |
string | Order number |
order.total_amount |
decimal | Total amount |
order.currency |
string | Currency |
lead.email |
string | Customer email |
lead.first_name |
string | Customer first name (currently resolves to lead.full_name) |
lead.last_name |
string | Customer last name (currently resolves to lead.full_name) |
campaign.name |
string | Campaign name |
transaction.tenant_reference_id |
string (nullable) | Merchant-owned reference from the session's /pay/ submission. null when not provided. |
transaction.status_changed
| Variable | Type | Description |
|---|---|---|
transaction.id |
uuid | Transaction ID |
transaction.merchant_transaction_id |
string | Merchant transaction ID |
transaction.tenant_reference_id |
string (nullable) | Merchant-owned reference from /pay/ submission. null when not provided. |
transaction.amount |
decimal | Amount |
transaction.currency |
string | Currency |
transaction.status |
string | New status |
transaction.type |
string | Type |
lead.email |
string | Customer email |
lead.full_name |
string | Customer name |
order.order_number |
string | Order number |
Endpoints can filter on specific status values via a status_filter list, so you only receive webhooks for statuses you care about (e.g., only approved transactions).
chargeback.created
| Variable | Type | Description |
|---|---|---|
chargeback.id |
uuid | Chargeback ID |
chargeback.merchant_transaction_id |
string | Merchant transaction ID |
chargeback.amount |
decimal | Amount |
chargeback.currency |
string | Currency |
chargeback.status |
string | Status |
chargeback.report_date |
datetime | Report date |
lead.email |
string | Customer email |
lead.full_name |
string | Customer name |
transaction.merchant_transaction_id |
string | Original transaction ID |
transaction.tenant_reference_id |
string (nullable) | Merchant-owned reference from the original /pay/ submission. null when unlinked or not supplied. |
Event routing
Webhooks are organized around a profile/endpoint hierarchy:
A Webhook Profile groups endpoints and carries the signing secret. Profiles are linked to campaigns via a many-to-many relationship, so the same profile can serve multiple campaigns.
A Webhook Endpoint listens for a specific event type and delivers to a specific URL.
For Direct Mode sessions (no campaign), webhook routing uses the webhook_profile_id explicitly attached to the Order.
Priority queues
| Queue | Used For |
|---|---|
webhooks_high |
Real-time events (API/HPP submissions) |
webhooks_low |
Bulk events (CSV imports) |
This separation ensures that a large CSV import does not delay delivery of real-time payment notifications.
Best practices
- Always verify signatures before processing webhook data
- Return 200 immediately, then process asynchronously
- Implement idempotency using entity IDs as dedup keys
- Use the webhook as the authoritative signal for order fulfillment, not the
/pay/response - Monitor your endpoint -- 20 consecutive failures will trigger the circuit breaker
- Use webhook.site for testing before deploying your production endpoint
Next steps
- HPP Integration -- See webhooks in context of a full integration
- Sandbox Testing -- Test webhook delivery and signature verification
- Reconciliation -- Pull data programmatically as an alternative to webhooks