ReferralPanda Docs

Webhooks

ReferralPanda can tell your server when something happens to a referral or a reward. Add an endpoint with POST /v1/webhook-endpoints (see the API reference). The response includes a signing secret, shown once.

Events

referral.attributedA new user was matched to the person who referred them.
referral.convertedThe referred user made the purchase your program asks for. The hold starts.
referral.qualifiedThe hold ended without a refund.
referral.voidedThe referral was cancelled, for example after a refund.
reward.createdA reward exists and is on hold.
reward.approvedA reward was cleared for delivery.
reward.issuedA reward was delivered. For webhook rewards, grant it now.
reward.failedDelivery was given up after several attempts.
reward.voidedA reward was cancelled before delivery.
reward.clawback_reviewA refund arrived after a reward had gone out. Nothing was reversed.
offer_code_pool.lowAn offer code pool is running out.

Payload

Every event has the same envelope. data holds ids, states and amounts (money is in minor units, for example cents). It never contains emails or your users' attributes: look those up with the ids.

{ "id": "evt_5f2c1d0b9a8e7f6a5b4c3d2e", "type": "reward.issued", "created_at": "2026-11-13T18:30:00.000Z", "data": { "reward_id": "3f2b8a10-5c1d-4e7a-9b2f-0a1b2c3d4e5f", "referral_id": "9d1c7e52-2b4a-4f0e-8c3d-6a5b4c3d2e1f", "recipient_customer_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "role": "referrer", "state": "issued", "amount_minor": null, "currency": null } }

Verify the signature

Each request carries a ReferralPanda-Signature header: t=1731500000,v1=5257a8…. v1 is the hex HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with your endpoint's signing secret. Reject anything that does not match or is more than five minutes old.

import { createHmac, timingSafeEqual } from 'node:crypto'; // rawBody must be the exact bytes of the request, before any JSON parsing. export function verify(secret, signatureHeader, rawBody) { const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.trim().split('='))); const timestamp = Number(parts.t); if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false; const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); const given = Buffer.from(parts.v1 ?? '', 'hex'); return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, 'hex')); }

Respond, retry, deduplicate