Webhooks
Referral Reactor can push real-time event notifications to your server via webhooks. When something happens in your organization — a referral is created, a bonus payment completes, a user joins — we send an HTTP POST request to your configured endpoint with a JSON payload describing the event.
Event Types
All 14 webhook event types are listed below, grouped by resource.
Referral Events
| Event Type | Description |
| ------------------------ | ------------------------------------------------------- |
| referral.created | A new referral was submitted |
| referral.updated | A referral was updated (status, fields, labels, photos) |
| referral.deleted | A referral was deleted |
| referral.message_added | A message was added to a referral conversation |
Bonus Payment Events
| Event Type | Description |
| ----------------------------------- | ------------------------------------------ |
| bonus_payment.created | A bonus payment was created |
| bonus_payment.status_updated | A bonus payment's status changed |
| bonus_payment.completed | A bonus payment was successfully processed |
| bonus_payment.failed | A bonus payment failed to process |
| bonus_payment.credentials_updated | Payment credentials were updated |
User Events
| Event Type | Description |
| -------------------------- | ------------------------------------- |
| user.joined | A user joined the organization |
| user.permissions_updated | A user's permission level was changed |
| user.status_updated | A user's status was updated |
| user.left | A user left the organization |
| user.profile_updated | A user's profile was updated |
Payload Schema
Every webhook delivery shares the same top-level envelope defined by WebhookEventPayload:
interface WebhookEventPayload {
eventType: string; // e.g. "referral.created"
eventId: string; // Unique ID for this event (UUID)
timestamp: number; // Unix timestamp (milliseconds) when the event occurred
organizationId: string; // ID of the organization that owns the resource
data: Record<string, unknown>; // Resource-specific payload (see below)
metadata?: Record<string, unknown>; // Optional additional context
}
Example payload — referral.created
{
"eventType": "referral.created",
"eventId": "evt_01j9abc123def456",
"timestamp": 1735689600000,
"organizationId": "org_2abc123",
"data": {
"referral": {
"id": "ref_01j9xyz789",
"name": "John Smith",
"email": "john@example.com",
"phone": "+1 555-123-4567",
"address": { "formatted": "821 Virginia Pine Ln, Clover, SC 29710" },
"notes": "Interested in a quote for solar panels.",
"status": "new",
"customFieldValues": { "150e5202-9ab0-4da5-98b7-bad64770e0a8": "1" },
"customFields": { "companySize": "1" },
"customFieldDetails": [
{
"id": "150e5202-9ab0-4da5-98b7-bad64770e0a8",
"name": "companySize",
"label": "Company Size",
"type": "text",
"value": "1"
}
],
"referrerId": "user_2def456",
"referrerName": "Jane Doe",
"createdAt": 1735689600000,
"updatedAt": 1735689600000
},
"referrer": {
"id": "user_2def456",
"name": "Jane Doe",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"phone": "+1 555-987-6543",
"company": "Acme Referrals"
},
"submittedBy": {
"clerkUserId": "user_2clerkabc",
"userId": "user_2def456",
"name": "Jane Doe",
"email": "jane@example.com",
"isReferrer": true
}
}
}
referral.* describes the referred person (the lead). referrer is the user who
submitted the referral and who bonus payments are credited to. submittedBy can
differ from referrer when an admin or marketer submits on a referrer's behalf —
isReferrer is false in that case. Any of these may be null when the
underlying record has no value.
referral.updated uses the same data shape, plus a statusChange object
({ "oldStatus": "new", "newStatus": "completed" }) when the update changed the
status.
Custom fields
The same custom field values are provided three ways, so you can pick the one that suits your integration:
| Key | Keyed by | Use when |
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
| customFields | Field Name (e.g. companySize) | Mapping fields in a no-code tool, or writing readable integration code |
| customFieldValues | Internal UUID | You need the permanent identifier, or you already parse this |
| customFieldDetails | Array with id, name, label, type | Rendering the values, or matching on either identifier |
customFields uses the Field Name set in Settings → Fields. Both identifiers
are durable, but they are durable in different ways, and the difference matters
if you are hard-coding a key:
- The UUID is the permanent identifier. It is assigned once and never changes for the life of the field.
- The Field Name cannot be changed after the field is created. Renaming is rejected precisely because integrations reference it. Admins change the Field Label instead, which is display-only and never appears as a key.
So neither key changes under a live integration. The one case that does change a name-keyed payload is deleting a field and creating a new one to replace it: that is a different field with a new UUID, and its name is whatever the admin chose. Treat that as a deliberate change requiring an integration update, the same as adding a field.
Two behaviors worth handling:
- A value whose field definition was deleted appears only in
customFieldValues. There is no longer a name to key it by, so it is absent fromcustomFieldsandcustomFieldDetails. - Field names are matched case-sensitively on the way in and emitted exactly as
the admin typed them, so mirror the casing shown in
customFieldDetailsrather than normalizing it.
Example payload — bonus_payment.completed
{
"eventType": "bonus_payment.completed",
"eventId": "evt_01j9abc789ghi012",
"timestamp": 1735693200000,
"organizationId": "org_2abc123",
"data": {
"bonusPaymentId": "bp_01j9mno345",
"referralId": "ref_01j9xyz789",
"amount": 50.0,
"currency": "USD",
"paymentMethod": "paypal_venmo",
"completedAt": 1735693200000
}
}
Example payload — user.joined
{
"eventType": "user.joined",
"eventId": "evt_01j9abc321pqr654",
"timestamp": 1735696800000,
"organizationId": "org_2abc123",
"data": {
"userId": "user_2ghi789",
"permissionLevel": "referrer",
"joinedAt": 1735696800000
}
}
Request Headers
Every webhook request includes the following HTTP headers:
| Header | Value / Description |
| ------------------------------ | -------------------------------------------------------------------- |
| Content-Type | application/json |
| User-Agent | ReferralReactor-Webhooks/1.0 |
| X-ReferralReactor-Signature | HMAC-SHA256 signature for verifying authenticity (see below) |
| X-ReferralReactor-Timestamp | Unix timestamp (seconds) when the request was sent |
| X-ReferralReactor-Event-Id | Unique ID for this event delivery (matches eventId in the payload) |
| X-ReferralReactor-Event-Type | Event type string (e.g. referral.created) |
Signature Verification
Each webhook request is signed so you can verify it genuinely came from Referral Reactor and has not been tampered with.
Algorithm: HMAC-SHA256
Signing key: Your webhook endpoint's secret (returned when the endpoint is created)
Signed payload: {timestamp}.{rawBody} — the X-ReferralReactor-Timestamp value, a literal ., then the raw request body string
Always verify the signature before processing the payload. Reject any request where the signatures do not match or where the timestamp is more than 5 minutes old (to prevent replay attacks).
Node.js verification example
import crypto from 'crypto';
export function verifyWebhookSignature(
rawBody: string,
timestamp: string,
signature: string,
secret: string
): boolean {
// 1. Reconstruct the signed payload
const signedPayload = `${timestamp}.${rawBody}`;
// 2. Compute the expected HMAC-SHA256 signature
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Compare using a timing-safe equality check
const sigBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (sigBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
}
// Express.js handler example
import express from 'express';
const app = express();
// Use raw body parser so we can verify the signature
app.post(
'/webhooks/referral-reactor',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-referralreactor-signature'] as string;
const timestamp = req.headers['x-referralreactor-timestamp'] as string;
const rawBody = req.body.toString('utf8');
const secret = process.env.WEBHOOK_SECRET!;
// Reject stale requests (older than 5 minutes)
const requestAge = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (requestAge > 300) {
return res.status(400).json({ error: 'Request timestamp too old' });
}
if (!verifyWebhookSignature(rawBody, timestamp, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
console.log('Received event:', event.eventType, event.eventId);
// Handle the event
switch (event.eventType) {
case 'referral.created':
// handle referral creation
break;
case 'bonus_payment.completed':
// handle payment completion
break;
// ... other event types
}
res.status(200).json({ received: true });
}
);
Important: Use
express.raw()(notexpress.json()) so the raw body string is preserved for signature verification. Parsing the body first will change the string and invalidate the signature check.
Retry Policy
If your endpoint does not return an HTTP 2xx response within the timeout window, Referral Reactor will retry the delivery automatically.
| Parameter | Value | | ---------------- | --------------------------------------------------------- | | Maximum attempts | 5 | | Initial delay | 1 second | | Backoff strategy | Exponential (delay doubles after each failed attempt) | | Maximum delay | 1 hour | | Jitter | Yes — a random offset is added to prevent thundering herd |
Retry schedule (approximate):
| Attempt | Delay before retry | | ------- | --------------------- | | 1 | 1 second | | 2 | 2 seconds | | 3 | 4 seconds | | 4 | 8 seconds | | 5 | 16 seconds (+ jitter) |
After 5 failed attempts the delivery is marked permanently_failed and no further retries are made.
Your endpoint should respond quickly (within a few seconds) and process the event asynchronously. Return
200 OKas soon as you have received and persisted the payload, then handle it in a background job.
Auto-Disable Behavior
To protect your infrastructure and ours, Referral Reactor automatically disables a webhook endpoint after repeated delivery failures. When an endpoint is disabled:
- No new events are delivered to it.
- The endpoint's
isDisabledfield is set totrueand adisabledReasonis recorded. - You will see the disabled status in the dashboard under Settings → Webhooks.
To re-enable a disabled endpoint you have two options:
- Dashboard — Navigate to Settings → Webhooks, find the disabled endpoint, and click Re-enable.
- API — Send a
PATCHrequest to update the endpoint:
curl -X PATCH https://app.referralreactor.com/api/v1/webhooks/{endpointId} \
-H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{ "isActive": true }'
After re-enabling, fix the underlying issue (unreachable URL, server errors, etc.) before events resume delivery.
Creating a Webhook Endpoint
Follow these steps to register a new webhook endpoint via the API.
Step 1 — Choose your event types
Decide which of the 15 event types you want to subscribe to. You can subscribe to all of them or only the ones relevant to your integration.
Step 2 — Create the endpoint
Send a POST request to /api/v1/webhooks with the required fields:
| Field | Type | Required | Description |
| ------------ | ---------- | -------- | ------------------------------------------------------- |
| name | string | Yes | A human-readable label for this endpoint |
| url | string | Yes | The HTTPS URL that will receive webhook POST requests |
| eventTypes | string[] | Yes | Array of event type strings to subscribe to |
curl -X POST https://app.referralreactor.com/api/v1/webhooks \
-H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{
"name": "My Production Webhook",
"url": "https://example.com/webhooks/referral-reactor",
"eventTypes": [
"referral.created",
"referral.updated",
"bonus_payment.completed",
"bonus_payment.failed"
]
}'
Step 3 — Store the secret
The response includes a secret field. This is the only time the secret is returned in plaintext. Store it securely (e.g. in your secrets manager or environment variables) — you will need it to verify incoming webhook signatures.
{
"id": "wh_01j9abc123",
"name": "My Production Webhook",
"url": "https://example.com/webhooks/referral-reactor",
"eventTypes": [
"referral.created",
"referral.updated",
"bonus_payment.completed",
"bonus_payment.failed"
],
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"isActive": true,
"createdAt": 1735689600000
}
Step 4 — Verify your endpoint
Send a test event from the dashboard (Settings → Webhooks → Send test event) or wait for a real event to fire. Confirm your server receives the request, verifies the signature, and returns 200 OK.
Step 5 — Go live
Your endpoint is now active. Events matching your subscribed eventTypes will be delivered in real time as they occur in your organization.
Next Steps
- Authentication — API key scopes and Bearer token usage
- API Reference — Interactive reference for all REST endpoints
- Getting Started — Make your first API call