onari

Webhooks

Get told when a player wins, verify it came from Onari, and pay exactly once.

View as Markdown

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):

  1. Add your URL. Live URLs must use https://.
  2. Copy the signing secret (whsec_…) into your server's environment.
  3. 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-…"
  }
}
typeWhendata
reward.issuedA 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_reachedA campaign has used 90% of its budget.campaign_id, campaign, spent, budget
campaign.budget_reachedThe budget is used up; nobody else can win.campaign_id, campaign, budget
leaderboard.finalizedA leaderboard closed and its winners were rewarded.campaign_id, campaign, winners
trivia.round_closedA "top N" quiz closed for the day (or ended) and its winners were rewarded.campaign_id, campaign, day, winners
campaign.endedA campaign ended.campaign_id, campaign
webhook.testYou 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:

HeaderValue
webhook-idmsg_…, the same on every retry of a message.
webhook-timestampUnix seconds when this attempt was sent.
webhook-signaturev1,<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 (or webhook-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.

On this page