Skip to content

Webhooks

Instead of polling every payment, register one endpoint and JET pushes each state change to you, signed, with retries and a dead-letter path.

One endpoint per organization. Registering a new URL replaces the old one.

Register an endpoint

Requires webhook.manage (admin or owner).

curl -X PUT "$JET_API/webhooks/endpoint" \
  -H "Authorization: Bearer $JET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.northwind.example/jet" }'
{
  "url": "https://hooks.northwind.example/jet",
  "secret": "whsec_kR3n…"
}

Danger

The secret is shown once. There is no read path back to it: a GET that returned it would turn a read-scoped credential leak into a forgeable-delivery leak. Store it before you do anything else.

Registering again mints a fresh secret and invalidates the old one.

URL requirements

Rule Why
https required Deliveries carry transaction data and an HMAC. Over plain HTTP, an observer sees the data and a replayer has everything needed to resend it
No loopback, link-local, or private addresses localhost, 127.*, 169.254.*, 10.*, 192.168.*, 172.16–31.*, .internal, .local are refused

The private-address rule exists because the webhook target is attacker-chosen: any authenticated caller could otherwise name a URL that makes us issue requests from inside our own network, on a schedule, with retries.

{
  "error": "validation",
  "issues": [{ "message": "webhook url must not target a loopback, link-local, or private address" }]
}

Read the registered endpoint

curl "$JET_API/webhooks/endpoint" -H "Authorization: Bearer $JET_API_KEY"
{ "url": "https://hooks.northwind.example/jet" }

{ "url": null } when nothing is registered: that is a normal state of a resource that exists, not a 404. The secret is never returned.

Delete the endpoint

curl -X DELETE "$JET_API/webhooks/endpoint" \
  -H "Authorization: Bearer $JET_API_KEY"
{ "url": null, "deleted": true }

Delivery stops and the signing secret is destroyed. The call is idempotent: deleting when nothing is registered returns 200 with deleted: false.

Info

Deliveries already queued are not cancelled. They stay durable and fail with "no endpoint configured" until they dead-letter. A delete that silently dropped queued work would be the one way this system could lose a notification.

What a delivery looks like

POST /jet HTTP/1.1
Host: hooks.northwind.example
Content-Type: application/json
X-Jet-Signature: sha256=9f2c…
X-Jet-Timestamp: 1787320991
X-Jet-Delivery-Id: dlv_6ac1…
X-Jet-Event: transaction.reconciled
{
  "deliveryId": "dlv_6ac1…",
  "event": "transaction.reconciled",
  "transactionId": "pay-2026-08-21-0001",
  "data": {
    "state": "RECONCILED",
    "sendAmount": { "amount": "100000", "currency": "USD", "scale": 2 },
    "receiveAmount": { "amount": "24512750", "currency": "VND", "scale": 0 },
    "corridor": { "from": "USD", "to": "VND", "destinationCountry": "VN" },
    "occurredAt": "2026-08-21T09:14:02.000Z",
    "provider": "tazapay",
    "payoutMode": "regular"
  }
}
Header Meaning
X-Jet-Signature sha256=<hex> HMAC over {timestamp}.{rawBody}
X-Jet-Timestamp Unix seconds, bound into the signature
X-Jet-Delivery-Id Stable across retries of the same delivery
X-Jet-Event The event type, also present in the body

Always verify the signature before trusting a delivery. See Verifying signatures.

Responding

Return any 2xx and we mark the delivery done. Anything else, or a timeout, is a failed attempt.

Respond fast and do your work asynchronously. A slow endpoint turns into retries, and retries turn into duplicate processing on your side.

Delivery guarantees

At-least-once. A delivery may arrive more than once: after a network blip, or a retry of a request we never saw succeed.

De-duplicate on deliveryId. A retry carries the same deliveryId, so recording IDs you have processed turns at-least-once into effectively exactly-once on your side.

Retries back off exponentially (roughly 1s, 2s, 4s, 8s, and so on) up to a small number of attempts, then the delivery dead-letters rather than being dropped. Nothing is silently lost.

Each retry is signed afresh with a new timestamp, so your staleness window never rejects our own legitimate retries.

Ordering is not guaranteed. Two events for the same payment can arrive out of order. Use the state in the payload as the truth, and if you need certainty, read GET /transactions/:id.

A robust receiver

1. Read the raw body: bytes, before any parsing
2. Verify the HMAC in constant time
3. Check the timestamp is within your tolerance (5 minutes is typical)
4. Have I seen this deliveryId? → return 200, do nothing
5. Record the deliveryId
6. Return 200
7. Process asynchronously

Steps 1–3 are non-negotiable. Steps 4–7 are what keep a retry storm from becoming a double-processing incident.

Next