Skip to content

Webhooks

We send a signed POST request to your endpoint whenever a payment or payout changes state. Webhooks are how you should learn that a payment is completed.

Setting up an endpoint

Add endpoints in Developers → Webhooks. Each endpoint belongs to one mode, has its own signing secret and can subscribe to all events or a chosen list. Live endpoints must use HTTPS, and every endpoint must resolve to a public internet address.

Events

PaymentsPayouts
payment.created
payment.pending
payment.processing
payment.completed
payment.underpaid
payment.failed
payment.expired
payment.cancelled
payment.on_hold
payment.refunded
payout.requested
payout.approved
payout.processing
payout.completed
payout.failed
payout.rejected
payout.cancelled

The Send test event button next to each endpoint in the dashboard sends a webhook.ping event.

Request format

POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Gateway-Event: payment.completed
X-Gateway-Event-Id: evt_01j9x6...
X-Gateway-Timestamp: 1760616000
X-Gateway-Signature: t=1760616000,v1=5f8c2b...

{
  "id": "evt_01j9x6...",
  "type": "payment.completed",
  "mode": "live",
  "created_at": "2026-10-16T12:00:00+00:00",
  "data": { "id": "pay_01j9x4...", "object": "payment", "status": "completed", ... }
}

data is the full payment or payout object, in the same format the API returns.

Verifying the signature

  1. Read the raw request body exactly as received, before any JSON parsing.
  2. Split X-Gateway-Signature on , to get t (Unix timestamp) and v1 (signature).
  3. Compute HMAC-SHA256(secret, t + "." + raw_body) as lowercase hex.
  4. Compare it with v1 using a constant-time comparison.
  5. Reject the request if t is more than 5 minutes from your current time. This blocks replayed requests.
// PHP
$payload   = file_get_contents('php://input');
$header    = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);   // ['t' => ..., 'v1' => ...]
$expected  = hash_hmac('sha256', $parts['t'] . '.' . $payload, getenv('GATEWAY_WEBHOOK_SECRET'));

if (! hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) $parts['t']) > 300) {
    http_response_code(400);
    exit;
}
$event = json_decode($payload, true);

More languages are in Code examples.

Responding and retries

  • Return any 2xx status within 10 seconds. Do slow work (emails, fulfilment) after responding, or in a queue.
  • Any other status, a timeout or a connection error counts as a failure. We retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. That is 8 attempts in total.
  • After the last attempt the delivery is marked failed. You can see every attempt and retry it manually under Developers → Webhooks.

Duplicates and ordering

  • We create each event once per status change, but a retry can deliver it again. Store X-Gateway-Event-Id and skip IDs you have already handled.
  • Events can arrive out of order. Check data.status rather than assuming order. If unsure, read the latest state with GET /payments/{id}.

Rolling the secret

Rolling an endpoint's secret in the dashboard replaces it immediately, and the old secret stops working. Update your server first, then roll.