Webhooks
A signed POST to your server when a payment confirms on the network. It is the only notification Brykto sends, and the only thing that proves a payment happened.
Setup
- Open Dashboard > Developer Tools.
- Enter your webhook URL and save.
- Copy your webhook secret.
Your URL is checked at delivery time and must be:
https, nothttp- Publicly resolvable. Private, loopback, link-local, reserved and multicast addresses are all refused
- Reachable without a login
Local development
A tunnelling tool that gives you a public HTTPS address works. localhost never will, because the check happens on Brykto's side.
The order.paid event
order.paid is the only event. The payload nests everything under data.
{
"event": "order.paid",
"timestamp": "2026-08-17T12:04:11.882431+00:00",
"data": {
"request_id": "3f9a1c2e-7b4d-4f81-9c22-1a5e6d8b0f33",
"amount": "49.9",
"currency": "USD",
"receive_asset": "USDC",
"status": "completed",
"description": "Order 1042",
"order_id": "1042",
"customer_id": "cust_88",
"customer_name": "Jane Smith",
"rail": "stellar",
"tx_hash": "a1b2c3d4e5f6...",
"caller_wallet": "GBXY...",
"amount_received": "49.9",
"paid_at": "2026-08-17T12:04:09+00:00",
"expires_at": "2026-08-17T12:30:00+00:00"
}
}
The payload is nested
Read event["data"]["tx_hash"], not event["tx_hash"].
| Field | Notes |
|---|---|
amount |
What was invoiced. A string, not a number |
amount_received |
What actually arrived, read from the network. Also a string |
caller_wallet |
The payer's address, read from the network |
tx_hash |
The on-chain transaction. Unique, and your replay key |
order_id |
Whatever you passed at creation |
amount and amount_received can differ. Brykto accepts a payment within 0.1% for USDC, USDT0 and RLUSD, and within 2% for XLM and XRP.
Headers
| Header | Contents |
|---|---|
X-Brykto-Signature |
sha256= followed by the hex digest |
X-Brykto-Timestamp |
Unix timestamp, and part of the signed string |
Verifying the signature
HMAC-SHA256 over {timestamp}.{body}, keyed with your webhook secret.
import hashlib
import hmac
def verify(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
signed = timestamp.encode("utf-8") + b"." + body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
import crypto from "crypto";
export function verify(body, signature, timestamp, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(timestamp + "."), body]))
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<?php
function brykto_verify(string $body, string $signature,
string $timestamp, string $secret): bool {
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
return hash_equals($expected, $signature);
}
Three things that break verification:
Sign the raw body
If your framework parses the JSON and you re-serialise it before hashing, key order and whitespace change and the signature will not match. Take the bytes exactly as received. In Express that means express.raw({ type: "application/json" }).
- The timestamp is the one in the header, not the
timestampinside the payload. - The header value includes the
sha256=prefix. Compare the whole value, or strip it from both sides.
Retries
Three attempts, 30 seconds apart. Your endpoint has 10 seconds to return a 2xx.
Reply first and do the slow work afterwards. A handler that sends email before responding will time out and be retried.
webhook_attempts, webhook_delivered_at and webhook_last_error are recorded against the payment.
Replay protection
tx_hash is unique per payment. Store the ones you have handled and ignore repeats.
if db.already_handled(data["tx_hash"]):
return "", 200
db.mark_handled(data["tx_hash"])
fulfil(data["order_id"])
Without this, a retry after a slow response ships the same order twice.
Security checklist
- Verify the signature on every call, and reject anything that fails
- Use the raw request body
- Ignore any
tx_hashyou have already handled - Compare
amountagainst your own record before fulfilling - Return 2xx quickly, then do the work
- Never fulfil from a browser redirect
Next steps
- Pay-per-access - a full worked example of webhook-gated delivery
- Payment Links API - the request that starts it all