Skip to content

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.

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.

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.

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.

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.

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.

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.