Outbound webhooks

Subscribe an HTTPS endpoint to Referalli events and verify each delivery's Standard Webhooks signature.

Embed
Preview of the card
<a href="https://referalli.com/docs/webhooks"><img src="https://referalli.com/docs/_api/public/orgs/07f432f4-34b9-46a7-aaad-c0e395d2e4f9/spaces/2bf4f614-3630-4fbf-85f9-5ecafa6b7d97/pages/db70c3fe-40cd-444f-a4dc-aec1f1fee25a/social-card.svg?v=3e7ae33eec8b" alt="Outbound webhooks" width="600" height="315"></a>

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

GET /v1/webhook-endpoints

List endpoints with status (active, paused or disabled), programIds, legacySignature, rotation, failingSince, disabledAt and disabledReason.

PATCH /v1/webhook-endpoints/{id}

Change any of status (active or paused), url, topics, programIds and legacySignature. Setting active re-enables a disabled endpoint.

POST /v1/webhook-endpoints/{id}/rotate-secret

Issue a new secret. Both sign deliveries for 24 hours.

DELETE /v1/webhook-endpoints/{id}

Remove an endpoint and its delivery history.

GET /v1/webhook-deliveries?endpointId=…

Deliveries, newest first and paginated (the latest 200 under 2026-10-21): topic, status, attempts, lastStatusCode, lastAttemptAt, and nextAt while it is still pending.

GET /v1/webhook-endpoints/{id}

One endpoint, with its status, rotation and failure state.

GET /v1/webhook-endpoints/{id}/deliveries

What that endpoint was sent: its deliveries, newest first and paginated.

GET /v1/webhook-deliveries/{id}

One delivery in full: the exact payload sent, its eventId, and what the endpoint answered last (lastStatusCode, lastResponseBody, the first 2,000 characters; or lastError when the request got no response).

POST /v1/webhook-deliveries/{id}/replay

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

webhook-id

The delivery's id. It stays the same on every retry and replay of that delivery, so use it to deduplicate.

webhook-timestamp

When this attempt was signed, in unix seconds.

webhook-signature

v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of the secret after whsec_. During a rotation there are two, separated by a space.

x-referally-topic

The event topic, same as topic in the body.

x-referally-signature

Deprecated. Hex HMAC-SHA256 of the raw body only, keyed with the whole secret string. Sent only while the endpoint's legacySignature is on: off for endpoints created since 2026-10-22 (pass "legacySignature": true to opt in), on for older ones until you turn it off. It has no timestamp, so it can't stop replays.

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.

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();
});

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

data fields

click.recorded

clickId, programId, partnerId, linkId

referral.created

The referral payload (below), plus matchedVia

referral.held

The referral payload, plus reasons and caseId: a risk or self-referral check put it in pending_review

referral.approved

The referral payload: approved by the merchant, a fraud case dismissed (reason: "fraud_case_dismissed", caseId), or a hold released by email verification (reason: "email_verified")

referral.voided

The referral payload, plus reason: voided by the merchant, a fraud case (fraud_case_voided, partner_banned, with caseId), or an identity provider (fraud_confirmed)

referral.paused, referral.resumed

The referral payload

referral.reassigned

The referral payload with the new partner, plus previousPartnerId and previousPartnerExternalRef

referral.rejected

programId, partnerId, customerRef, reason (self_referral): a self-referral the program's selfReferralPolicy: "reject" refused. A fraud.case_opened follows.

customer.aliased

fromCustomerRef, toCustomerRef, referralIds, partnerId (see Auth platforms)

commission.earned

commissionId, partnerId, programId, amountMinor, currency, referralId, customerRef, billingEventId, providerEventId and invoiceRef (of the payment that earned it), basisMinor (the amount a percentage was applied to; null for fixed amounts and bounties), level (0 direct, 1–5 sub-affiliate), memo, customerExternalRef and partnerExternalRef (your own ids for the customer and partner, or null; see Auth platforms). Payment fields are null for signup and trial bounties.

commission.reversed

commissionId and relatedCommissionId (both the original line), reversalId (the new reversed event), partnerId, programId, amountMinor (negative), currency, referralId, customerRef, billingEventId, providerEventId and invoiceRef (of the refund or dispute), level, memo, customerExternalRef, partnerExternalRef

partner.applied

enrollmentId, programId, partnerId (enrollmentId is absent for applications from the public program page)

partner.enrolled

programId, partnerId, enrollmentId

partner.approved

programId, partnerId, and enrollmentId when approved from an application

partner.level_up

programId, partnerId, level, referrals

payout.scheduled

payoutId, partnerId, amountMinor, currency

payout.submitted

payoutId, partnerId, amountMinor, currency

payout.sent

payoutId, partnerId, amountMinor, currency

payout.failed

payoutId, partnerId, reason

payout.cancelled

payoutId, partnerId (one per payout when a payout run is cancelled)

tax.form_delivered

documentId, partnerId, taxYear, form (1099-nec), corrected, emailed (whether the filing provider also emailed it)

commission.settled

commissionId, partnerId, programId, referralId, amountMinor, currency, method (store_credit or merchant_credit), reference

referee_reward.earned

rewardId, referralId, programId, customerRef, customerExternalRef, kind, amountMinor, currency, code, billingEventId, commissionId

fraud.case_opened

caseId, kind (self_referral, quality or ring), plus partnerId, referralId or members depending on the kind. A self-referral rejected by policy has referralId: null and rejected: true.

deal.submitted

dealId, partnerId, company

dispute.resolved

disputeId, resolution

privacy.request_completed

requestId, type (access or erasure), status (completed or failed), and found when completed. See Privacy.

webhook_endpoint.disabled

endpointId, url, reason, lastStatusCode, days. Sent to your other active endpoints when one is disabled for failing.

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.

Updated