Bridge by Wekoya / Docs / Your account
Webhooks
Updated
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, asGET /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_startedandworkload.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,.resumedand.deleted. The data hasorganization_id,user_id,role,previous_roleandoccurred_at; deliveries can arrive in any order, so order them byoccurred_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)
// })