# Webhooks

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

Source: https://docs.onari.xyz/docs/webhooks

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:

```json
{
  "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](https://www.standardwebhooks.com) 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.

<Tabs items={["Node.js", "Python", "Any language"]}>
<Tab value="Node.js">

```ts
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);
});
```

</Tab>
<Tab value="Python">

```python
import os
from standardwebhooks.webhooks import Webhook
from flask import Flask, request

wh = Webhook(os.environ["ONARI_WEBHOOK_SECRET"])
app = Flask(__name__)

@app.post("/onari/webhooks")
def onari_webhook():
    try:
        msg = wh.verify(request.get_data(as_text=True), dict(request.headers))
    except Exception:
        return "Bad signature", 400
    if msg["type"] == "reward.issued" and msg["data"]["reward"]["kind"] != "voucher":
        pay_once(msg["data"]["idempotency_key"], msg["data"]["player_id"], msg["data"]["reward"])
    return "", 200
```

</Tab>
<Tab value="Any language">

1. Read `webhook-id`, `webhook-timestamp` and `webhook-signature`.
2. Reject timestamps more than 5 minutes from now.
3. Base64-decode your secret after the `whsec_` prefix.
4. Compute `base64(HMAC-SHA256(secret, "{id}.{timestamp}.{raw body}"))`.
5. Compare it, in constant time, with each space-separated `v1,<signature>` in `webhook-signature`.

</Tab>
</Tabs>

## 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.
