Using Payday
Webhooks
Register an HTTPS endpoint and Payday tells your server the moment a request is ready, funded, settled, expired, or returned, and every time funds go back to the payer. Each delivery is signed; each event happens once.
Register an endpoint#
The URL must be a public HTTPS destination with a DNS hostname and no credentials in it. Payday resolves and rejects private, loopback, and reserved addresses when the endpoint is created and again before every delivery, and never follows redirects.
curl -fsS "$API/v1/webhooks" \
-H "Authorization: Bearer $PAYDAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/payday/webhook" }'{
"id": "wh_0198f80c-3333-7dc1-a369-90556a64f700",
"url": "https://example.com/payday/webhook",
"secret": "whsec_…",
"created_at": "2026-09-06T12:00:00Z",
"disabled_at": null
}The secret is returned once. Later reads of the endpoint omit it. To rotate, register the same URL again for a new secret and disable the old endpoint; its delivery history stays readable. Send yourself a test with POST /v1/webhooks/{id}/test, which queues a webhook.test event to that endpoint alone.
Events#
| Event | When |
|---|---|
| deposit_request.ready | The payer attested their wallet and the one-time address now exists. The moment an integration may quote the address. |
| verification.approved | The payer policy was satisfied: a mailbox proven, or a merchant-session secret exchanged. |
| deposit_request.deposited | Finalized credits reached the amount. Settlement is queued. |
| deposit_request.settled | Exactly the amount reached your payout address. |
| deposit_request.expired | The deadline passed before settlement. |
| deposit_request.returned | The whole balance went back to the payer's wallet after expiry. |
| deposit_request.recovered_funds | Funds went back to the payer's wallet: an overpayment remainder, an expired balance, or a late transfer. One event per amount returned, so a request can raise this more than once. |
| deposit_request.likely_unsolicited | Finalized funds first arrived from a wallet other than the attested one. |
| deposit_request.needs_attention | Automatic movement paused. The payload carries the same attention object the API shows. |
| webhook.test | Sent on request to one endpoint. Carries no deposit request. |
The lifecycle events (deposited, settled, expired, returned, needs_attention) happen at most once per request, and each is written in the same database transaction that changes the request, so an event can never exist without its state change or the other way around.
What a delivery looks like#
Every delivery is a POST with three headers and a JSON envelope. The data.deposit_request object is a strict subset of the API's own deposit request, under the same names, units, and formats, so a handler can hand id straight to the API and compare amount without conversion.
POST /payday/webhook HTTP/1.1
Content-Type: application/json
Payday-Event-Id: evt_0198f80c-4444-7dc1-a369-90556a64f700
Payday-Event-Type: deposit_request.settled
Payday-Signature: v1,t=1756728000,sha256=6f1a…9c0e
{
"id": "evt_0198f80c-4444-7dc1-a369-90556a64f700",
"type": "deposit_request.settled",
"occurred_at": "2026-09-01T12:00:00Z",
"data": {
"deposit_request": {
"id": "dr_0198f80c-8d2f-7dc1-a369-90556a64f700",
"status": "settled",
"amount": "10.500000",
"amount_base_units": "10500000",
"received": "10.500000",
"received_base_units": "10500000",
"heading": "March retainer",
"reference": "INV-1042",
"metadata": { "po": "PO-77" },
"customer_id": null,
"issuer_id": "iss_0198f80c-1111-7dc1-a369-90556a64f700",
"payer_policy_mode": "merchant_session",
"payer_reference": "user_123",
"verification_completed_at": "2026-09-01T11:58:00Z",
"likely_unsolicited_at": null,
"payer_wallet": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
"address": "0x2222222222222222222222222222222222222222",
"wallet_bound_at": "2026-09-01T11:58:30Z",
"expires_at": "2026-09-02T11:57:00Z",
"created_at": "2026-09-01T11:57:00Z"
}
}
}payer_referenceis your own id for the payer on a merchant-session request andnullotherwise, so a settled handler credits the right ledger directly.payer_wallet,address, andwallet_bound_atarenullbeforedeposit_request.ready.- The payload never carries the expected email or any payer data beyond what you asserted.
- A settled event for an overpaid request looks the same as one for an exact deposit; the recovery is its own event.
deposit_request.recovered_funds adds data.recovery:
{
"type": "deposit_request.recovered_funds",
"data": {
"deposit_request": { "id": "dr_0198f80c-…", "status": "settled", "…": "…" },
"recovery": {
"id": "rec_0198f80c-2222-7dc1-a369-90556a64f700",
"amount": "0.250000",
"amount_base_units": "250000",
"reason": "overpayment",
"transaction_hash": "0x…",
"block_number": "12345",
"recovered_at": "2026-09-01T12:00:00Z"
}
}
}Verify the signature#
Payday-Signature: v1,t=1756728000,sha256=6f1a…9c0e
- v1
- The scheme. Reject anything else.
- t
- Unix seconds when Payday signed the delivery. Reject a timestamp older than your tolerance (five minutes is usual).
- sha256
- Hex HMAC-SHA256, keyed with the endpoint's secret, over the bytes
v1.<t>.<raw body>. Compare in constant time.
Compute HMAC-SHA256 with the endpoint's secret over the bytes v1.<timestamp>.<raw body>, compare it with the header's hex in constant time, and reject timestamps outside your tolerance. Use the raw request bytes; a body that has been parsed and re-serialised will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: Buffer, header: string, secret: string): boolean {
const [scheme, ...rest] = header.split(",");
if (scheme !== "v1") return false;
const parts = Object.fromEntries(rest.map((p) => p.split("=", 2) as [string, string]));
if (!parts.t || !parts.sha256) return false;
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`v1.${parts.t}.`)
.update(rawBody)
.digest("hex");
const given = Buffer.from(parts.sha256, "hex");
const want = Buffer.from(expected, "hex");
return given.length === want.length && timingSafeEqual(given, want);
}
// Express: keep the raw bytes; a re-serialised body will not verify.
app.post("/payday/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.header("Payday-Signature") ?? "", process.env.PAYDAY_WEBHOOK_SECRET!)) {
return res.status(400).end();
}
const event = JSON.parse(req.body.toString("utf8"));
if (await alreadyHandled(event.id)) return res.status(200).end(); // idempotent
await handle(event);
res.status(200).end();
});Retries and idempotency#
- Answer with any
2xxwithin a few seconds. Do the work afterwards if it is slow. - A non-
2xxanswer, a timeout, or a connection failure is retried with exponential backoff, up to 12 attempts, with the gap capped at one hour. Redirects are not followed. - After the twelfth failure the delivery is
failedand stays that way. The deliveries route shows every attempt with its status, error, time, and duration, so a missed event can be reconciled by hand. - Deduplicate on
Payday-Event-Id. A retry carries the same id and the same body.
Poll or subscribe?#
Use webhooks for anything automatic: crediting a ledger, sending a receipt, releasing an order. Use the API's long polling (wait_for=change) for a screen that is open right now. Treat the API as the source of truth: a handler that reads the request back before acting is never wrong, whatever order deliveries arrived in.