Webhooks
Five events, HMAC-signed, four attempts and then stop.
Warrant posts a signed JSON body to your endpoint on five warrant events, retries three times with bounded backoff, and then gives up.
POST /your/endpoint HTTP/1.1
Warrant-Signature: t=1786788003,v1=b1946ac92492d2347c6235b4d2611184…
Warrant-Event: warrant.released
Warrant-Delivery: whd_01J8Z3M4YQ7K2V9XABCDEFGHJK
Warrant-Attempt: 1
Content-Type: application/jsonEndpoints are created in the console
There is no POST /v1/webhooks. Endpoints are registered at
console.gowarrant.xyz/webhooks, which is also the only place the signing
secret is ever shown. The endpoint below is a session-authenticated read-back
and needs the admin role.
The five events
| Event | Fires when |
|---|---|
warrant.opened | Funds are held. |
warrant.verifying | A check has started. |
warrant.released | Paid in full. |
warrant.partially_released | A share was paid. |
warrant.refunded | The hold returned to available. |
An endpoint subscribing to an empty events array receives all of them.
That is the default, and it is deliberate: a webhook that silently misses an
event because someone forgot to tick a box is worse than one that is noisy.
There is no warrant.escalated event. An escalation reaches a person through
push, Telegram or email, not through your endpoint.
Verifying the signature
Warrant-Signature is t=<unix>,v1=<hex>, where the hex is
HMAC-SHA256(secret, "<timestamp>.<body>").
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret: string, header: string, body: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
// Reject anything older than five minutes, or a replayed capture is valid
// forever.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Sign over the raw body bytes, before any JSON parsing. Re-serialising changes whitespace and key order, and the signature will not match.
The tolerance is 300 seconds. @warrant/db exports verifySignature as the
reference implementation, and the tests assert against it rather than
re-deriving the format.
Delivery and retries
Four attempts total, backing off 1s, 5s, then 25s. A 2xx is success; anything
else is a failure. After the fourth the delivery is exhausted and is not
retried again — a bad endpoint cannot occupy the worker forever.
Deliveries are driven by POST /internal/jobs/webhooks, a worker route behind a
shared secret. It is not part of the public API.
Respond 2xx quickly and do the work afterwards. An endpoint that takes eight seconds to answer is an endpoint that will start seeing retries.
Read endpoints and recent deliveries
GET /v1/console/webhooks — admin and up, session only. Returns registered
endpoints plus the 50 most recent delivery attempts.
GET /v1/console/webhooks HTTP/1.1
Host: api.gowarrant.xyz
Cookie: warrant_session=…200
{
"object": "list",
"data": [
{
"id": "whk_01J8Z3M4YQ7K2V9XABCDEFGHJK",
"url": "https://example.com/hooks/warrant",
"events": [],
"disabled": false,
"created_at": "2026-08-01T09:14:22.000Z"
}
],
"deliveries": [
{
"id": "whd_01J8Z3M4YQ7K2V9XABCDEFGHJK",
"webhook_id": "whk_01J8Z3M4YQ7K2V9XABCDEFGHJK",
"event": "warrant.released",
"status": "delivered",
"attempts": 1,
"response_code": 200,
"next_attempt_at": null,
"created_at": "2026-08-15T12:02:11.000Z"
}
]
}curl -sS https://api.gowarrant.xyz/v1/console/webhooks \
-b "warrant_session=$WARRANT_SESSION"The signing secret is never in this response. It is shown once when the endpoint is created and is not recoverable — rotate it by replacing the endpoint.
Amounts in a payload
The event body carries the trace payload, so amounts follow the same convention
as everywhere: integer strings of the asset's smallest unit, ^[0-9]+$.
"8400000" 8.40 USDC
8400000 never sent — a JSON number
"8.40" never sent — not the smallest unitOn warrant.partially_released, amount is the full figure and released_bps
is the share paid. Multiply, do not assume amount is what moved.