Developers
Webhooks
When a payment request changes state we POST a signed JSON body to
the webhook_url
you set on it. Verify the signature before you act on one — a merchant
who does not can be told "paid" by anybody.
Events
Three events, and no others
This is the complete list the code dispatches. Nothing else is sent, so nothing else is worth waiting for.
payment.paid
The request is settled in full. Fulfil the order on this one.
payment.late
A payment arrived after the request had already expired. It is credited, but the order may need a manual look.
payment.expired
The window closed without full payment. Any partial amount received is stated in the payload.
Why payment.late is separate.
Money that arrives after an invoice expired is still credited to your
balance — it is on the chain, and refusing to see it would not make it
go away. But sending payment.paid for it
would tell you to release goods for an order you had already written
off, so it gets its own event and a decision of yours.
The payload
What arrives
One flat JSON object, always the same shape. There is no envelope and no nesting.
Content-Type: application/json
X-CryptoGate-Event: payment.paid
X-CryptoGate-Signature: 8ffc140c0e1f51a8…be5a03b4d9ba2ec8
{
"event": "payment.paid",
"id": "k3n8qzv2rt7wp1xd9fhs4bmc",
"type": "invoice",
"status": "paid",
"asset": "BTC",
"chain": "bitcoin",
"amount_requested": "0.0125",
"amount_paid": "0.0125",
"reference": "ORD-1042",
"address": "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx",
"paid_at": "2026-08-05T11:31:07+00:00",
"timestamp": "2026-08-05T11:31:09+00:00"
}
event
string
Which of the three events this is. Matches the X-CryptoGate-Event header.
id
string
The public slug of the payment request. The same value the hosted page URL ends in.
type
string
invoice or donation.
status
string
The request status at the moment of sending: pending, partial, paid, overpaid, expired or cancelled.
asset
string
BTC, ETH, LTC or MATIC.
chain
string
bitcoin, ethereum, litecoin or polygon.
amount_requested
decimal string | null
Null on a donation link. Never a JSON number.
amount_paid
decimal string
Confirmed total received so far. On payment.expired this is any partial amount that arrived before the window closed.
reference
string | null
Your own order id, exactly as you sent it. This is what to match against.
address
string
The receive address belonging to this request alone.
paid_at
ISO 8601 | null
When the request first settled.
timestamp
ISO 8601
When this delivery was generated. A retry of the same event carries the original value, so it is a replay marker, not a clock.
Signature
Verify before you act
Every delivery carries an HMAC-SHA256 of the exact bytes we sent, keyed with your signing secret, hex-encoded in X-CryptoGate-Signature. There is no prefix, no timestamp element and no version tag — the header is the digest.
Where the secret comes from
Your dashboard, under API and webhooks. It starts with whsec_ and is stable across API key rotation, so replacing a key does not break a working receiver. Rotate it deliberately when you need to; callbacks signed with the old one stop verifying immediately.
Sign the raw body
Compute the HMAC over the bytes as they arrived, before any JSON parsing. Re-encoding the parsed object will change key order or whitespace and the digest will not match.
Compare in constant time
Use hash_equals in PHP or crypto.timingSafeEqual in Node. A plain string comparison leaks, byte by byte, how much of a forged signature was right.
<?php
// The RAW body. json_decode first and re-encoding will not reproduce it.
$raw = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_CRYPTOGATE_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, getenv('CRYPTOGATE_WEBHOOK_SECRET'));
// Constant time. A == comparison tells an attacker how far they got.
if (! hash_equals($expected, $signature)) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// Same event twice is normal. Make the write idempotent.
$order = Order::where('reference', $event['reference'])->first();
if ($event['event'] === 'payment.paid' && $order && ! $order->fulfilled) {
$order->markFulfilled($event['amount_paid']);
}
// 2xx means "received". Anything else is retried.
http_response_code(200);
Do not skip this in development
An unverified receiver takes a POST from anyone who knows the URL. The
usual way this ends is a stranger sending your own endpoint a
payment.paid for an order they never paid
for, and your system shipping it.
Delivery
Retries, and the same event twice
Deliveries are queued and sent by a scheduled worker, never inline with the payment. Your endpoint being slow or down cannot hold up someone else's payment, and it cannot lose your callback either.
The retry schedule
Any 2xx marks the delivery done. Anything else — a 500, a timeout, a refused connection — is retried with exponential backoff, up to six attempts in total, then marked permanently failed.
- 1st attempt immediately
- 2nd attempt 1 minute later
- 3rd attempt 2 minutes later
- 4th attempt 4 minutes later
- 5th attempt 8 minutes later
- 6th attempt 16 minutes later, then it stops
Each attempt allows 10 seconds. Redirects are not followed, so a 302 on your endpoint reads as a failure rather than being chased somewhere else. Recent deliveries and their status codes are listed in your dashboard.
Assume you will see it twice
A receiver that returns 200 after the connection has already dropped is recorded as a failure here and retried. That is not a bug we can remove — at-least-once is the only honest guarantee across a network — so a receiver has to tolerate a repeat.
-
Key on the pair
The id plus the event is stable across retries. Record it, and drop a delivery you have already processed.
-
Make the effect idempotent
Prefer "mark this order fulfilled" over "increment the balance". The first is safe to repeat; the second is not.
-
Answer fast, work later
Verify, enqueue, return 200. Doing the fulfilment inline risks a timeout, and a timeout is a retry.
-
Expect them out of order
Deliveries are attempted in id order, but a retried payment.partial can land after the payment.paid that followed it. Trust the status field over arrival order.
Your endpoint must be public HTTPS. A webhook URL is resolved and refused if it points at loopback, a private range or link-local — and it is re-checked at send time, not only when you save it, because DNS can be repointed at an internal host in between. For local development, use a tunnel that gives you a public HTTPS hostname.
That is the whole integration
A key, a POST, and a signature check. If something in these docs is wrong or missing, tell us — we would rather fix the page than have you guess.