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.attributed | A new user was matched to the person who referred them. |
referral.converted | The referred user made the purchase your program asks for. The hold starts. |
referral.qualified | The hold ended without a refund. |
referral.voided | The referral was cancelled, for example after a refund. |
reward.created | A reward exists and is on hold. |
reward.approved | A reward was cleared for delivery. |
reward.issued | A reward was delivered. For webhook rewards, grant it now. |
reward.failed | Delivery was given up after several attempts. |
reward.voided | A reward was cancelled before delivery. |
reward.clawback_review | A refund arrived after a reward had gone out. Nothing was reversed. |
offer_code_pool.low | An 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
- Answer with any 2xx status within 10 seconds. Do slow work after you respond.
- Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After the sixth failed attempt the delivery is abandoned.
- The same event can arrive more than once. Its
idstays the same across retries, so store the ids you have handled and skip repeats. - Redirects are not followed, and the endpoint must be a public https address.
GET /v1/webhook-endpoints/{id}/deliveriesshows recent deliveries, and you can send one again with itsredeliverroute.