# Webhooks

URL: https://bridge.wekoya.tech/docs/webhooks
Updated: 2026-11-08

> Add an HTTPS URL on the End users page, or with POST /v1/webhooks and { "url": "...", "events": ["*"] }. The signing secret is shown once. Events:

Add an HTTPS URL on the End users page, or with `POST /v1/webhooks` and `{ "url": "...", "events": ["*"] }`. The signing secret is shown once. Events:

- `end_user.budget_low`: a user has used 80% of this period's budget (the percentage is a setting). Sent once per period.
- `end_user.budget_exhausted`: a user is out. Sent once per period, and again after a top-up or a raised budget if they run out again.
- `end_user.created`, `.topped_up`, `.reset`, `.suspended`, `.resumed`, `.deleted`.
- `balance.low`: your own balance fell below the threshold you set. Sent once, then again after you top up above it.
- `batch.completed`, `batch.failed`, `batch.cancelled`, `batch.expired`: a batch ended. The data is the batch, as `GET /v1/batches/{id}` returns it.
- `workload.run_completed`, `workload.recommendation` (a cheaper model is worth trying), `workload.degraded` (a live name's answers got worse), `workload.trial_started` and `workload.trial_stopped` (Bridge started a trial, or stopped one that did worse).
- On a company: `organization.member.added`, `.member.removed`, `.member.role_changed`, `organization.ownership_transferred`, `organization.invite.accepted`, `.invite.declined`, `organization.suspended`, `.resumed` and `.deleted`. The data has `organization_id`, `user_id`, `role`, `previous_role` and `occurred_at`; deliveries can arrive in any order, so order them by `occurred_at`.
- `webhook.test`: from the Send test button.

```
{
  "id": "5b0c9f5e-...",
  "type": "end_user.budget_low",
  "created_at": "2026-10-14T08:03:11Z",
  "data": {
    "id": "e3a1...",
    "external_id": "user_8412",
    "status": "active",
    "allowance_minor": "500000",
    "top_up_minor": "0",
    "used_minor": "402500",
    "remaining_minor": "97500",
    "period": "monthly",
    "period_end": "2026-11-01T09:12:00Z",
    "metadata": { "plan": "student" },
    "threshold_percent": 80
  }
}
```
Each delivery carries `Bridge-Signature: t=<unix time>,v1=<hex>`, an HMAC-SHA256 of `t.body` with your secret. Check it, and reject anything more than five minutes old. Answer with any 2xx within 10 seconds. Failures are retried with backoff for about a day, so handle the same `id` arriving twice.

```
import crypto from "node:crypto"

// Read the raw body before any JSON parser touches it: the signature is over the exact bytes.
export function verifyBridge(secret, rawBody, header) {
  const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.trim().split("=")))
  const t = Number(parts.t)
  if (!parts.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest()
  const given = Buffer.from(parts.v1, "hex")
  return given.length === expected.length && crypto.timingSafeEqual(given, expected)
}

// Express:
// app.post("/bridge-webhook", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyBridge(process.env.BRIDGE_WEBHOOK_SECRET, req.body.toString(), req.get("Bridge-Signature")))
//     return res.sendStatus(400)
//   const event = JSON.parse(req.body)
//   if (event.type === "end_user.budget_exhausted") notifyUser(event.data.external_id)
//   res.sendStatus(200)
// })
```
