Outbound webhooks
Subscribe an HTTPS endpoint to Referalli events and verify each delivery's Standard Webhooks signature.
Webhooks push events to your server as they happen: a new referral, an earned commission, a payout that settled. Each delivery is a JSON POST signed the Standard Webhooks way, so you can reject forged and replayed deliveries. Failed deliveries are retried for three days, and every delivery can be inspected and replayed.
Register an endpoint
curl -X POST https://referalli.com/v1/webhook-endpoints \
-H "Authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://example.com/hooks/referally",
"topics": ["referral.created", "commission.earned", "commission.reversed"]
}'{
"id": "whe_01JB...",
"url": "https://example.com/hooks/referally",
"topics": ["referral.created", "commission.earned", "commission.reversed"],
"legacySignature": false,
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}topics defaults to ["*"], which receives every event. To send an endpoint only one program's events, add "programIds": ["prg_..."]: it then receives events about those programs only, and org-wide events with no program (such as payouts) aren't sent to it. Leave it out, or set null or [], for every program. The secret is returned only here; store it to verify signatures. It is whsec_ followed by a base64 key, the Standard Webhooks format.
Call | Purpose |
|---|---|
| List endpoints with |
| Change any of |
| Issue a new secret. Both sign deliveries for 24 hours. |
| Remove an endpoint and its delivery history. |
| Deliveries, newest first and paginated (the latest 200 under |
| One endpoint, with its status, rotation and failure state. |
| What that endpoint was sent: its deliveries, newest first and paginated. |
| One delivery in full: the exact |
| Queue a delivery again with its attempt count reset. |
Changing endpoints and replaying require the owner or admin role.
Delivery format
POST /hooks/referally HTTP/1.1
content-type: application/json
webhook-id: whd_01JB4X...
webhook-timestamp: 1792600000
webhook-signature: v1,+xJ35/YtD1Xkp8yDurdHWWys5eBy+FTQji/7DJUI8Nw=
x-referally-topic: commission.earned
x-referally-signature: 5b1f0c7e9a...
{
"id": "evt_01JB...",
"topic": "commission.earned",
"orgId": "org_01J...",
"createdAt": "2026-10-21T15:04:05.123Z",
"data": {
"commissionId": "cme_01JB...",
"partnerId": "ptr_...",
"programId": "prg_...",
"amountMinor": 1980,
"currency": "usd"
}
}Header | Value |
|---|---|
| The delivery's id. It stays the same on every retry and replay of that delivery, so use it to deduplicate. |
| When this attempt was signed, in unix seconds. |
|
|
| The event topic, same as |
| Deprecated. Hex HMAC-SHA256 of the raw body only, keyed with the whole secret string. Sent only while the endpoint's |
Verify the signature
Verify the raw body bytes before parsing JSON. Reject the delivery when the signature doesn't match, or when webhook-timestamp is more than 5 minutes from your clock: that is what stops a captured delivery from being replayed later. Each SDK has a verifier that does both.
- Node.js (Express)
- Python (Flask)
- Without an SDK
import express from 'express';
import { constructWebhookEvent } from '@referally/sdk-js';
const app = express();
app.post('/hooks/referally', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
// Pass both secrets while you rotate: [process.env.NEW_SECRET, process.env.OLD_SECRET]
event = constructWebhookEvent(req.body.toString('utf8'), req.headers, process.env.REFERALLY_WEBHOOK_SECRET!);
} catch {
return res.status(401).end();
}
// handle event.topic / event.data, deduplicating on req.headers['webhook-id']
res.status(200).end();
});import os
from flask import Flask, request, abort
from referally import verify_webhook
app = Flask(__name__)
@app.post("/hooks/referally")
def referally_hook():
if not verify_webhook(request.get_data(), request.headers, os.environ["REFERALLY_WEBHOOK_SECRET"]):
abort(401)
event = request.get_json()
# handle event["topic"] / event["data"], deduplicating on request.headers["webhook-id"]
return "", 200import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, h: Record<string, string>, secret: string): boolean {
const ts = Number(h['webhook-timestamp']);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = `v1,${createHmac('sha256', key).update(`${h['webhook-id']}.${ts}.${rawBody}`).digest('base64')}`;
return (h['webhook-signature'] ?? '')
.split(' ')
.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}The Ruby (Referally.verify_webhook), Go (referally.VerifyWebhook) and PHP (Referally\Webhook::verify) SDKs have the same verifier. All of them are tested against one set of shared test vectors, which you can also use for your own implementation.
Moving off the legacy signature
Endpoints created before 2026-10-22 also get the old x-referally-signature header (newer ones don't, unless they opted in). Once your receiver checks webhook-signature, turn the old one off with PATCH /v1/webhook-endpoints/{id} and {"legacySignature": false}, or the Turn off legacy signature button on the Webhooks page. The legacy header will stop being sent after a deprecation window, which is announced in the changelog.
Rotate the secret
POST /v1/webhook-endpoints/{id}/rotate-secret returns a new secret:
{ "id": "whe_01JB...", "secret": "whsec_cE0sPAbmtGN5ZY3MvUn/Kk4cvE6rRZtz", "previousSecretExpiresAt": "2026-10-22T15:04:05.000Z" }For the next 24 hours every delivery carries two signatures in webhook-signature, one per secret, so a receiver holding either one keeps verifying. Deploy the new secret within that window (or pass both secrets to the SDK verifier while you roll out), and after it only the new secret signs. GET /v1/webhook-endpoints shows rotation.inProgress and rotation.previousSecretExpiresAt during the overlap.
Retries
A delivery succeeds when your endpoint answers with any 2xx status within 15 seconds. Any other status, a timeout or a network error schedules a retry with exponential backoff: 30 seconds, then doubling each attempt (60 seconds, 2 minutes, 4 minutes and so on) with ±20% jitter, up to 6 hours between attempts. A delivery keeps retrying for 3 days, then is marked failed; replay it once your endpoint is fixed. Deliveries are queued in order of creation but are not guaranteed to arrive in order.
Failing endpoints are disabled
When every delivery to an endpoint has failed for 5 days, Referalli disables the endpoint (status: "disabled", with disabledAt and disabledReason). The merchant sees a notification and the org owner gets an email (through your configured email provider). A disabled endpoint receives nothing new, and its pending deliveries are held, not dropped. Fix the endpoint, then re-enable it with PATCH /v1/webhook-endpoints/{id} and {"status": "active"} (or Re-enable on the Webhooks page): held deliveries resume, and the failure clock starts over. A single successful delivery also resets it.
Pausing an endpoint ({"status": "paused"}) holds its deliveries the same way, until you set it back to active.
Event topics
Topic |
|
|---|---|
|
|
| The referral payload (below), plus |
| The referral payload, plus |
| The referral payload: approved by the merchant, a fraud case dismissed ( |
| The referral payload, plus |
| The referral payload |
| The referral payload with the new partner, plus |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Every referral.* event carries the same referral payload, the referral as it is after the change: referralId, programId, partnerId, partnerExternalRef (the partner's id in your system, or null), customerRef and status. An event fires only when the referral actually changed.
click.recorded fires for every click, so subscribe to it only if you need click-level data.