Webhooks

The RefRef event catalog, how to receive and verify webhook events, read the event feed, and confirm Rewards.

RefRef tells your product about facts through events: a new participant, a bound referral, a recorded Event, a Reward that was earned or voided, a payout. You receive them in two ways:

  • Webhooks. RefRef sends each event as a signed POST to every enabled endpoint of the Project that subscribes to its type.
  • The event feed. GET /v1/webhook-events lists the same events in commit order. The feed is the source of truth: if your endpoint missed deliveries, read the feed from your last cursor.

RefRef writes an event in the same database transaction as its fact. An event exists exactly when its fact was saved.

Event catalog

TypeWhen RefRef sends itdata
participant.createdA trusted call or a Widget token named a participant that the Project did not have, or a partner joined itparticipant: { participantId, kind, externalId }. A partner has externalId: null.
referral.createdAttribution bound a referralreferral: id, programId, referrer, referee, bindingEventId or bindingSubscriptionFactId, boundAt
event.recordedA signup or purchase got its attribution decision, or RefRef accepted a custom_ Eventevent (type, participant, actor, source IDs, purchase amounts) and attribution (null for custom_)
reward.earnedA participant earned a Rewardreward
reward.voidedAn operator voided a Rewardreward
reward.fulfilledA Reward was fulfilled: by your product, the Console, a webhook answer, or a RefRef deliveryreward
reward.revokedYour product took back a voided Reward that it grantedreward
payout.paidA payout paid cash Rewards of the Projectpayout
payout.failedA payout failed before payment; its Rewards wait for the next payoutpayout, with failureReason
payout.returnedThe provider returned a payout, before or after payment; the next payout pays its Rewards againpayout, with failureReason

The API reference publishes a JSON Schema and a sample for each type.

The envelope

Every event has the same envelope:

{
  "id": "wev_…",
  "type": "reward.earned",
  "apiVersion": "2026-10-04",
  "occurredAt": "2026-10-04T12:00:00.000Z",
  "projectId": "prj_…",
  "environment": "live",
  "data": { "reward": { … } }
}
FieldMeaning
idThe event ID. Every delivery of one event has the same ID: deduplicate on it.
typeOne of the types in the catalog
apiVersionThe version of the payload shape. An endpoint keeps the version that was current when you created it.
occurredAtWhen the fact was saved
projectIdThe Project of the fact
environmentlive or sandbox, the environment of the Project
dataThe object as the fact left it

Inside one apiVersion, RefRef only adds fields and event types. Ignore fields and types that you do not know. A change that removes or renames a field is a new version.

The Reward object

reward.* events carry the Reward as GET /v1/rewards/{rewardId} returns it:

FieldMeaning
id, programId, ruleIdThe Reward and the rule that earned it
eventIdThe Event that earned it
beneficiaryRolereferrer or referee in a referral Program, subject in a loyalty Program
beneficiary{ participantId, kind, externalId }: who gets the Reward. Use externalId to find the user in your product.
benefitKindWhat the Reward gives. Exactly one of money, discount, or custom is set for it.
money{ amountMinor, netAmountMinor, currency, currencyExponent }
discount{ rateBps }
custom{ quantity, unitKey, unitName, unitNamePlural, validityDays }, for example 500 storage_mb
fulfillmentStatuspending, in_progress, fulfilled, failed, cancelled, revocation_pending, or revoked
fulfillment, revocationWho recorded the grant or the take-back, and when
delivery{ method, status, reason, providerReference }. Your product grants a Reward whose method is operator.
voidSet when an operator voided the Reward

The payout object

payout.* events carry one disbursement, limited to the Rewards of the Project: { id, method, currency, amountMinor, rewards: [{ rewardId, beneficiary, amountMinor }], failureReason }. One transfer to a partner can pay Rewards of several Projects, so each Project gets an event with only its own lines. A line of a voided Reward that was paid before is negative.

Add an endpoint

In the Console, open Developers → Webhooks and add an endpoint. Choose its URL, its event types, and its fulfillment mode. The Console shows the signing secret once; copy it then. Every Workspace member sees the endpoints; only owners and admins change them.

The endpoint menu also has Edit, Send test event…, and Rotate secret. Each endpoint keeps a log of its delivery attempts, and an admin can replay a delivery from it.

With the API

An automation app or your backend can subscribe with a Workspace API key:

curl -X POST "$REFREF_API/v1/webhook-endpoints" \
  -H "x-api-key: $REFREF_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "projectId": "prj_…",
    "url": "https://example.com/refref/webhook",
    "eventTypes": ["reward.earned", "reward.voided"],
    "fulfillmentMode": "explicit"
  }'

The answer has endpoint and its secret. The secret shows only in this answer.

RoutePurpose
POST /v1/webhook-endpointsSubscribe a URL. eventTypes defaults to every type.
GET /v1/webhook-endpoints?projectId=…List the Project's endpoints, without secrets
DELETE /v1/webhook-endpoints/{endpointId}?projectId=…Unsubscribe. The events stay in the feed.

The key acts only on Projects of its Workspace and its environment.

Receive and verify

RefRef signs each request with Standard Webhooks headers, so the Standard Webhooks libraries verify it.

HeaderValue
webhook-idThe event ID
webhook-timestampUnix seconds when RefRef signed the request
webhook-signaturev1,<base64 HMAC-SHA256> of <webhook-id>.<webhook-timestamp>.<raw body>. Several are space-separated.

The HMAC key is the base64-decoded text after whsec_ in the secret. After you rotate a secret, the old secret also signs for 24 hours, and the header has one signature for each secret. Accept the request when any one of them matches.

import { createHmac, timingSafeEqual } from "node:crypto";

/** Whether the Standard Webhooks headers sign `rawBody` with `secret`. */
export function verifyRefRefWebhook(
  rawBody: string,
  headers: Headers,
  secret: string,
) {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signature = headers.get("webhook-signature");
  if (!id || !timestamp || !signature || !/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  return signature.split(" ").some((part) => {
    const given = Buffer.from(part.slice("v1,".length), "base64");
    return (
      part.startsWith("v1,") &&
      given.length === expected.length &&
      timingSafeEqual(given, expected)
    );
  });
}

Verify the raw body, before your framework parses it. A body that was parsed and serialized again can differ by one byte and fail the check. Refuse a timestamp more than five minutes from your clock.

Answer and retries

Your answerWhat RefRef does
2xxThe delivery succeeded
202 Accepted to reward.earnedThe delivery succeeded; the Reward stays pending (see below)
Another status, or no answer in 10 secondsRefRef tries again

RefRef retries after 30 seconds, doubles the wait up to one hour, then tries each hour, for 80 attempts (about 72 hours). Then the delivery fails, and a Console admin can replay it. RefRef follows no redirect and sends only to https URLs on public addresses.

RefRef does not guarantee the order of deliveries. Each event carries the object as its fact left it, so compare occurredAt or read the object again when order matters. A delivery can also repeat: make your handler idempotent on the event ID, or on the Reward ID for reward.*.

Grant Rewards

Your product grants a Reward whose delivery.method is operator: account credit, a discount, or a custom quantity such as storage. The endpoint's fulfillment mode decides when RefRef records the grant:

  • on_delivery (the default). A 2xx answer to reward.earned records the fulfillment. Grant the Reward before you answer. Answer 202 Accepted to a Reward that this endpoint does not grant, for example another unit; the Reward then stays pending.
  • explicit. Your answer only acknowledges the delivery. When the grant is done, confirm it:
POST /v1/rewards/{rewardId}/fulfillment
x-api-key: <Workspace API key>
content-type: application/json

{ "projectId": "prj_…", "externalReference": "grant-123" }

Use explicit when your handler queues the work, and for automation platforms, which answer before their later steps run. A repeat of the confirmation with the same facts returns the same record.

When an operator voids a Reward that you granted, you receive reward.voided. Take the grant back in your product, then record it with POST /v1/rewards/{rewardId}/revocation (body { projectId, externalReference }). A cash Reward is never revoked. RefRef then sends reward.revoked.

To find Rewards that a webhook did not reach, read GET /v1/rewards?deliveryMethod=operator&fulfillmentStatus=pending.

Read the event feed

GET /v1/webhook-events lists the Project's events in commit order:

curl "$REFREF_API/v1/webhook-events?projectId=prj_…&limit=100" \
  -H "x-api-key: $REFREF_API_KEY"
{ "events": [ … ], "next": "…" }

Store next and pass it as after on the next read. An event never appears later behind a cursor that you already read. When there are no new events, next is the cursor that you sent. limit is 1 to 500; the default is 100.

Use the feed to catch up after downtime, to backfill a new system, or instead of webhooks when your product cannot receive requests.

Test an endpoint

Send test event… in the endpoint menu sends a signed sample of the type you choose, at once. Its ID starts with wev_test_, and RefRef does not store it, so it is not in the feed. Its objects do not exist: a call to confirm its Reward answers 404.

Automation platforms

Zapier, Make, and Activepieces connect with a webhook trigger and HTTP steps. See Automations for step-by-step recipes.

On this page