Webhooks
odnoga calls your app when money runs low or out — a user's credits, or your organisation's credit — so you can warn the user, top them up, or pause a feature, without asking odnoga every minute. Add them under Connect → Webhooks (workspace admins).
Events
| Event | When |
|---|---|
end_user.wallet_low | A user's wallet falls below 20% of what the user was granted. |
end_user.wallet_exhausted | A user's wallet reaches zero: their next requests get 402 end_user_exhausted until you top up. |
organisation.credit_low | Your organisation's credit falls below $5. |
organisation.credit_exhausted | Your organisation's credit reaches zero: requests are refused until you top up. |
test | Sent when you press Send test. |
Each event is sent once, when the balance crosses the line — not on every request below it.
What arrives
POST to your address, JSON body:
{
"id": "6f1c…",
"type": "end_user.wallet_exhausted",
"created_at": "2026-10-17T09:12:03Z",
"workspace_id": "5df4…",
"data": { "end_user_id": "user_123", "balance_units": 0, "unit": "credits" }
}
data for the organisation events is { "credit_usd": 4.2, "currency": "usd" }; wallet_low adds granted_units.
Headers: odnoga-event (the type), odnoga-delivery (unique per delivery — use it to ignore a repeat), and
odnoga-signature.
Answer with any 2xx within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2, 6 and
12 hours; the settings page shows every delivery and why one failed. Redirects are not followed.
Checking the signature
The secret (whsec_…) is shown once, when you add the webhook. odnoga-signature is t=<unix time>,v1=<hex>,
where v1 is HMAC-SHA256 of "<t>.<raw body>" with that secret. Reject a call whose signature does not match or
whose time is more than 5 minutes off.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyOdnoga(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify the raw body, before any JSON parsing — re-serialising changes the bytes.
Showing balances
To show a balance in your app at any moment, read it rather than keeping your own copy:
GET /v1/balance.