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

  1. Serialize the JSON payload with sorted keys and compact separators: json.dumps(payload, sort_keys=True, separators=(',', ':'))
  2. Compute HMAC-SHA256 using the webhook profile's signing secret as the key
  3. The result is a lowercase hex digest in the X-CatalystPay-Signature header

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

  1. Always verify signatures before processing webhook data
  2. Return 200 immediately, then process asynchronously
  3. Implement idempotency using entity IDs as dedup keys
  4. Use the webhook as the authoritative signal for order fulfillment, not the /pay/ response
  5. Monitor your endpoint -- 20 consecutive failures will trigger the circuit breaker
  6. Use webhook.site for testing before deploying your production endpoint

Next steps