> ## Documentation Index
> Fetch the complete documentation index at: https://docs.jenzy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Event catalog, delivery semantics, and the signature-verification recipe.

Hermes tells your system that something durable happened — a payout reached its terminal state, a trade settled, your balance dropped below its floor — by POSTing an event to your webhook endpoint.

## Registering an endpoint

An org admin registers the endpoint in the Jenzy portal (Integration settings): one HTTPS URL, one active endpoint per org. Registration mints a **signing secret** (`whsec_…`), shown exactly once — store it beside your API key. Replacing the endpoint mints a new secret; deliveries already in flight keep using the old one until they finish, so verify against **both** secrets while a rotation is in progress.

## The event envelope

Every delivery is a JSON POST with the same envelope:

```json theme={null}
{
  "id": "6a1f0d0e-3f0a-4c6f-9b1e-8d2c5a7e9f31",
  "event_type": "payout.succeeded",
  "created_at": "2026-07-08T09:14:31.000Z",
  "balance": { "available": 1245000, "held": 45000, "currency": "MWK" },
  "data": { "...": "the resource — see below" }
}
```

* `id` — your **dedupe handle**. Delivery is at-least-once: the same event can arrive more than once, and events can arrive out of order. Process each `id` once and ignore repeats.
* `balance` — a convenience snapshot of your balance as of when the event was prepared. **Never do balance math off webhooks** — events can arrive out of order; `GET /balance` is the authoritative read.
* `data` — the resource, rendered byte-identically to the corresponding `/v1` read endpoint.

## Event catalog

| Event              | When                                                                                                                                 | `data`                                                                                          |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| `payout.succeeded` | A payout reached its terminal success state.                                                                                         | The payout, as `GET /payouts/{id}` renders it.                                                  |
| `payout.failed`    | A payout failed; its hold is already released.                                                                                       | The payout, with `failure: { code, message }` set — see [failure codes](/errors#failure-codes). |
| `trade.settled`    | An OTC funding trade landed; your balance is credited.                                                                               | The trade, as `GET /trades` renders it.                                                         |
| `balance.low`      | Your available balance is below your configured floor. At most one per cooldown window while the condition holds; no recovery event. | `{ "balance": …, "floor_mwk": … }`                                                              |

## Verifying signatures

Every delivery carries a `Signature` header: **HMAC-SHA256 of the exact raw request body, keyed with your signing secret, hex-encoded**. There are no timestamp headers and nothing else to canonicalize — sign the bytes you received, compare in constant time.

<Warning>
  Compute the HMAC over the **raw body bytes**, before any JSON parsing. Parsing and re-serializing will reorder or reformat and the signature will never match.
</Warning>

```javascript Node.js (Express) theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'

const app = express()

// express.raw so req.body is the untouched Buffer the signature covers
app.post('/webhooks/jenzy', express.raw({ type: 'application/json' }), (req, res) => {
  const secret = process.env.JENZY_WEBHOOK_SECRET // whsec_…
  const expected = createHmac('sha256', secret).update(req.body).digest('hex')
  const received = req.get('Signature') ?? ''

  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(received, 'utf8')
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(401).end()
  }

  const event = JSON.parse(req.body)

  // At-least-once delivery: drop events you have already processed.
  // if (await alreadyProcessed(event.id)) return res.status(200).end()

  switch (event.event_type) {
    case 'payout.succeeded':
    case 'payout.failed':
      // event.data is the payout — same shape as GET /payouts/{id}
      break
    case 'trade.settled':
      // event.data is the trade — same shape as GET /trades items
      break
    case 'balance.low':
      // event.data is { balance, floor_mwk }
      break
  }

  res.status(200).end() // respond 2xx fast; do heavy work async
})
```

During a secret rotation, verify against both the old and new secret and accept if either matches.

## Delivery semantics

* Only a **2xx** response counts as delivered. Redirects are failures and are never followed. Respond quickly (within 10 seconds) and do real work asynchronously.
* A failed delivery is re-attempted **5 times over \~6 hours**, front-loaded. Every attempt sends the identical bytes — the payload is snapshotted when the event occurs, so the signature stays valid across attempts.
* After the last failed attempt the delivery is dead-lettered and Jenzy ops are alerted; it is never auto-revived. If you missed events during an outage, contact Jenzy to arrange re-delivery — and reconcile against `GET /payouts` in the meantime (polling and webhooks carry the same data, so the list endpoint is always a valid catch-up path).
