Skip to content
Warrantv0.1

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/json

Endpoints 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

EventFires when
warrant.openedFunds are held.
warrant.verifyingA check has started.
warrant.releasedPaid in full.
warrant.partially_releasedA share was paid.
warrant.refundedThe 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 unit

On warrant.partially_released, amount is the full figure and released_bps is the share paid. Multiply, do not assume amount is what moved.