Webhooks

When a payment reaches a terminal status, Cryptunnel POSTs a signed JSON body to the payment's callback_url, or to the merchant-level callback URL from the cabinet when the payment has none. No URL on either - no webhook. Verify the signature before trusting anything in the body: it proves the call came from Cryptunnel and the body was not altered.

When it fires

Once per terminal status: confirmed, confirmed_manual, failed, expired. Nothing is sent for created or pending. What each status means is on Payment statuses.

The payload

{
  "status": "confirmed",
  "external_id": "order-1042",
  "id": "n9cdFaTccYbXecVekHKW8Q",
  "original_amount": "100",
  "original_currency": "USD",
  "amount": "100.0034",
  "currency": "USDT",
  "terminal_id": "DEPRECATED",
  "merchant_id": "540a0d3a-5506-4fc7-bffa-985013e835a9"
}
FieldMeaning
statusconfirmed, confirmed_manual, failed or expired. Deliver on the first two.
external_idYour order id from payment creation.
idThe Cryptunnel payment id.
original_amount, original_currencyThe invoice price in the currency you asked for.
amount, currencyThe crypto amount and currency the buyer was asked to send, including the trace amount when auto_trace was on. Absent on expired - nobody picked a currency.
merchant_idYour merchant public id.
link_idOnly on payments born from a payment link: the link's public id.
terminal_idAlways the string DEPRECATED. A leftover from an older payload; ignore it. It will be dropped in a future version.

Your own callback_params from metadata are merged into the body first, so a name clash is won by the system fields - a buyer can never inject status or external_id through a payment link URL. The body does not carry is_test; keep that on your side from the creation response.

The signature

Two headers accompany every delivery:

  • x-webhook-timestamp - unix seconds when this delivery was signed
  • x-webhook-signature - hex HMAC-SHA256(webhook_secret, "<timestamp>.<raw body>")

Your webhook secret (whsec_...) is on the merchant page in the cabinet. Compute the HMAC over the raw request body bytes, compare in constant time, and reject timestamps further than five minutes from your clock - that closes replay.

# raw body in body.json, headers in $TIMESTAMP and $SIGNATURE
{ printf '%s.' "$TIMESTAMP"; cat body.json; } | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //'
# equal to $SIGNATURE, and $TIMESTAMP within 300 s of now → genuine
from cryptunnel import verify_webhook

# headers: any mapping, any casing; raw_body: bytes as received
if not verify_webhook(WEBHOOK_SECRET, headers, raw_body):
    ...  # 401 - never raises, returns False for tampered, stale or malformed input
import { verifyWebhook } from 'cryptunnel'

// headers: a Headers object or a plain record; rawBody: Buffer or string as received
if (!verifyWebhook(WEBHOOK_SECRET, headers, rawBody)) {
  ... // 401 - never throws, returns false for tampered, stale or malformed input
}
use Cryptunnel\Webhook;

// $headers: $_SERVER, getallheaders() or a framework bag; $rawBody: file_get_contents('php://input')
if (!Webhook::verify(WEBHOOK_SECRET, $headers, $rawBody)) {
    ... // 401 - never throws, returns false for tampered, stale or malformed input
}

The SDK helpers do the constant-time compare, the length guard and the 300-second tolerance (adjustable) for you.

Getting the raw body

The single most common bug is verifying a body the framework already parsed and re-serialised. Where the bytes are:

FrameworkRaw body
Expressexpress.raw({ type: 'application/json' }) on the route, then req.body is a Buffer
FastifyaddContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => done(null, body))
Next.js route handlerawait request.text()
Flaskrequest.get_data()
aiohttpawait request.read()
FastAPI / Starletteawait request.body()
Djangorequest.body
Laravel$request->getContent()
Plain PHP, WordPressfile_get_contents('php://input')

Parse the JSON only after the signature checks out.

Respond and retries

Answer with any 2xx once you have stored the event. Any other status, or no answer within the timeout, makes Cryptunnel retry: 60 attempts, one per minute, for one hour. After that the delivery is dropped; the payment itself is unaffected and stays readable through GET /v1/payments/{id}.

Every retry is signed afresh: a new x-webhook-timestamp and therefore a new x-webhook-signature over the unchanged body. There is no event id and no attempt counter, so deduplicate on the pair (id, status): the same payment reaching the same status is the same event, whatever the signature. Make the handler idempotent - deliver once, answer 2xx every time.


Did this page help you?