Docs
Webhooks
Steadi Pay POSTs a signed JSON event when a payment succeeds or fails. Verify the signature before you fulfil the order.
Add an endpoint
In the portal, Developers → Your apps, add an HTTPS URL. The webhook secret is shown once. We POST the event body as JSON with these headers:
SteadiPay-Signature:t={unix},v1={hmac}SteadiPay-Event-IdContent-Type: application/json
Events
| type | When |
|---|---|
| payment.succeeded | Provider confirmed the collection. Safe to fulfil. |
| payment.failed | Confirmed failure or a failed attempt. No money was taken. |
{
"id": "evt_...",
"type": "payment.succeeded",
"created_at": "2026-09-19T12:01:22.000Z",
"data": {
"id": "pay_...",
"status": "succeeded",
"merchant_reference": "TICKET-8841",
"gross_amount_minor": "25650",
"merchant_amount_minor": "25000",
"fee_amount_minor": "650"
}
}Verify the signature
Read the raw body as a string. Parse t and v1 from the header. Reject the event if t is more than 300 seconds off. Compute HMAC-SHA256 of t + "." + rawBody using the endpoint secret. Compare to v1 with a constant-time check.
import { createHmac, timingSafeEqual } from "crypto";
function verifySteadiPay(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const ts = Number(parts.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}Return HTTP 2xx quickly. We time out after 10 seconds.
Retries
Failed deliveries are retried at 30s, 1m, 5m, 15m, 1h and 6h. You can replay a delivery from the portal. Treat events as idempotent: store SteadiPay-Event-Id or the payment id and ignore duplicates.
Do not
- Fulfil from an unsigned body.
- Fulfil from the return URL without a verify or a valid webhook.
- Log the webhook secret or the raw signature in customer tickets.