Webhooks
Set callbackUrl on a payment and Stablezact will POST to it as the payment
progresses. This is the reliable way to know a payment has settled, because it
does not depend on the customer keeping a browser tab open.
config={{ amount: 49.99, currency: 'USDC', callbackUrl: 'https://yourstore.com/webhooks/stablezact', orderId: 'ORD-1234', metadata: { basket: 'b-99' },}}Both orderId and metadata come back on every event, which is how you match
an event to an order without keeping your own mapping.
Events
Section titled “Events”Read type. It is the same small vocabulary whichever way the customer paid.
type |
Meaning | Fulfil |
|---|---|---|
payment.pending |
money has been seen, the payment is not final yet | no |
payment.succeeded |
the customer paid | yes |
payment.underpaid |
less arrived than invoiced | no, prompt the customer |
payment.failed |
expired, cancelled, or failed | no |
payment.refunded |
money went back to the customer | reverse the order |
settlement.completed |
your payout reached your wallet | already fulfilled |
settlement.delayed |
your payout is slower than usual, retrying on its own | nothing to do |
settlement.failed |
your payout could not be completed, we contact you | nothing to do |
webhook.test |
a test delivery | no |
payment.succeeded is the only one that means fulfil. Everything else is there
if you want it.
The payment events answer “should I ship this order”. The settlement events
answer “where is my money”, and they never change whether you ship. Plenty of
integrations ignore them and reconcile from their wallet instead.
type is also sent as the X-Webhook-Type header, if you would rather route on
a header than parse the body.
The older event field
Section titled “The older event field”Every payload also carries an event field, which predates type. It uses two
naming conventions, gives the same outcome two different names depending on how
the customer paid, and names our internal settlement steps. It still works and
will keep working, so existing integrations do not need changing.
Build new integrations against type. If you are reading an older one, this is
how the two line up:
event |
type |
|---|---|
payment_success, deposit.confirmed |
payment.succeeded |
payment_failure, deposit.failed, bridge.failed |
payment.failed |
deposit.detected, bridge.filled |
payment.pending |
deposit.swept, settlement.completed |
settlement.completed |
settlement.delayed |
settlement.delayed |
payment.underpaid |
payment.underpaid |
payment.refunded |
payment.refunded |
webhook_test |
webhook.test |
The payment is paymentId in every payload. Some older events also call it
transactionId; both are present and identical. The payout transaction is
settlementTxHash, also present as sweepTxHash on older events.
If you are unsure which event you are looking at, fetch the payment and read
outcome. It is the same answer, and it does not depend on which events
reached you.
Verifying the request came from us
Section titled “Verifying the request came from us”Every delivery carries two headers:
X-Webhook-Signature: sha256=<hex digest>X-Webhook-Timestamp: <milliseconds since epoch>Verify before acting on the body. An endpoint that fulfils orders on an unverified POST will fulfil orders for anyone who finds the URL.
Use the SDK helper if you can
Section titled “Use the SDK helper if you can”The SDK ships the verification, so you do not have to reimplement it:
import { verifyWebhookSignature } from 'stablezact-pay';
app.post('/webhooks/stablezact', express.json(), (req, res) => { const result = verifyWebhookSignature( req.body, req.headers['x-webhook-signature'], req.headers['x-webhook-timestamp'], process.env.STABLEZACT_WEBHOOK_SECRET );
if (!result.valid) return res.status(400).json({ error: result.error });
res.sendStatus(200); handleEvent(req.body);});There is also coinleyWebhookMiddleware if you would rather mount it as
middleware.
Verifying it yourself
Section titled “Verifying it yourself”import crypto from 'node:crypto';
function verify(payload, signatureHeader, timestampHeader, secret) { const ts = parseInt(timestampHeader, 10);
// Reject anything older than five minutes, so a captured delivery cannot be // replayed later. if (Date.now() - ts > 5 * 60 * 1000) return false;
const withTimestamp = { ...payload, timestamp: ts }; const serialized = JSON.stringify(withTimestamp, Object.keys(withTimestamp).sort());
const expected = crypto.createHmac('sha256', secret).update(serialized).digest('hex'); const received = signatureHeader.replace(/^sha256=/, '');
const a = Buffer.from(received, 'hex'); const b = Buffer.from(expected, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b);}Three details that matter:
The timestamp is part of the signed material, so it has to be added to the
payload before serialising, as a number.
Keys are sorted. JSON.stringify(value, Object.keys(value).sort()) is the exact
call the server makes.
Your webhook secret must be at least 32 characters. Shorter secrets are rejected by the verifier rather than silently weakening the signature.
Handling deliveries
Section titled “Handling deliveries”Respond 200 quickly and do the work afterwards. A slow endpoint looks like a
failed one.
Handle the same event arriving twice. Retries and network timeouts mean
at-least-once delivery, so keying on paymentId plus event and ignoring
repeats is worth the few lines it takes.
