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

EventWhen
end_user.wallet_lowA user's wallet falls below 20% of what the user was granted.
end_user.wallet_exhaustedA user's wallet reaches zero: their next requests get 402 end_user_exhausted until you top up.
organisation.credit_lowYour organisation's credit falls below $5.
organisation.credit_exhaustedYour organisation's credit reaches zero: requests are refused until you top up.
testSent 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.