Webhooks
Get told when a player wins, verify it came from Onari, and pay exactly once.
Onari sends an HTTPS POST to your URL when something happens, most importantly reward.issued when a player wins something you pay for.
Set it up
In the dashboard, open Developers → Webhooks in each environment (sandbox and live have separate URLs and secrets):
- Add your URL. Live URLs must use
https://. - Copy the signing secret (
whsec_…) into your server's environment. - Click Send a test and check your endpoint replies
2xx.
What we send
Every message has the same JSON envelope:
{
"type": "reward.issued",
"timestamp": "2026-10-20T09:14:40Z",
"environment": "live",
"data": {
"reward_id": "a91e5b7c-…",
"player_id": "user_8a21",
"campaign_id": "6f1c…",
"campaign": "October challenge",
"reason": "challenge",
"reward": { "kind": "cashback", "value": 50000, "currency": "NGN" },
"status": "issued",
"idempotency_key": "rw_a91e5b7c-…"
}
}type | When | data |
|---|---|---|
reward.issued | A player won. Pay them, unless it's a voucher code (already handed out). | reward_id, player_id, campaign_id, campaign, reason (challenge, streak, leaderboard, referral, referral_friend, trivia), reward (kind, value, currency, and code or perk), status, idempotency_key |
budget.threshold_reached | A campaign has used 90% of its budget. | campaign_id, campaign, spent, budget |
campaign.budget_reached | The budget is used up; nobody else can win. | campaign_id, campaign, budget |
leaderboard.finalized | A leaderboard closed and its winners were rewarded. | campaign_id, campaign, winners |
trivia.round_closed | A "top N" quiz closed for the day (or ended) and its winners were rewarded. | campaign_id, campaign, day, winners |
campaign.ended | A campaign ended. | campaign_id, campaign |
webhook.test | You clicked Send a test. | message |
Amounts are in minor units. Ignore types you don't need; reply 2xx anyway.
Verify the signature
Onari follows the Standard Webhooks format, so there's a ready-made library for most languages. Every request has three headers:
| Header | Value |
|---|---|
webhook-id | msg_…, the same on every retry of a message. |
webhook-timestamp | Unix seconds when this attempt was sent. |
webhook-signature | v1,<base64 HMAC-SHA256> of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with your secret (base64-decoded, after whsec_). |
Always verify against the raw request body, before parsing JSON.
import express from "express";
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.ONARI_WEBHOOK_SECRET!);
const app = express();
app.post("/onari/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
let msg;
try {
msg = wh.verify(req.body.toString("utf8"), req.headers as Record<string, string>) as { type: string; data: any };
} catch {
return res.status(400).send("Bad signature");
}
if (msg.type === "reward.issued" && msg.data.reward.kind !== "voucher") {
// Pay once: your payments table has a unique column for the idempotency key.
await payOnce(msg.data.idempotency_key, msg.data.player_id, msg.data.reward);
}
res.sendStatus(200);
});Retries
Reply with any 2xx within 15 seconds. Anything else (or no reply) is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours: 8 attempts over about 22 hours. After that the message is marked failed and your team can resend it from Developers → Webhooks, where every message shows its payload and your endpoint's last reply.
Redirects aren't followed, and messages are sent from Onari's servers to public addresses only.
Pay exactly once
Deliveries are at least once: after a timeout we can't know whether you processed the message, so we send it again. Two habits make that safe:
- Store
idempotency_key(orwebhook-id) with each payment, in a column with a unique constraint, and skip keys you've already paid. - Reply quickly. Record the message and reply
200, then do slow work (calling your payment provider) in the background.