Webhooks

Receive signed referral lifecycle events to your own environment

Invitebase delivers referral lifecycle events to your server as HTTPS POST requests. Webhooks are how you find out a referral validated, a reward needs granting, or your balance is running low — without polling. Every delivery is HMAC-signed so you can prove it came from Invitebase before you act on it.

This page is the delivery reference. For the end-to-end recipe of fulfilling in-app rewards from a webhook, see In-app rewards via webhook.

Register an endpoint

Endpoints are mode-scoped: register one endpoint (or set) for test mode and one for live mode. Each endpoint subscribes to the event types you choose.

Dashboard — go to Developer tools → Webhooks, add your URL, and pick event types. The signing secret is displayed once at creation and cannot be retrieved later — if you lose it, roll it to mint a new one.

API — create endpoints programmatically (see the webhook endpoints reference):

curl https://api.invitebase.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $INVITEBASE_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/invitebase/webhooks",
    "enabled_events": ["referral.validated", "reward.pending", "reward.failed"]
  }'

The response includes the endpoint's signing_secret (whsec_…) — returned only on creation. Store it next to your API keys.

The delivery envelope

Every delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
Invitebase-SignatureTimestamp and HMAC signature, e.g. t=1767960000,v1=5257a869e7…
Invitebase-EventThe event type, e.g. reward.pending — route deliveries without parsing the body
Invitebase-DeliveryUnique id for this endpoint/event delivery, stable across retries — a natural idempotency key
User-AgentInvitebase-Webhooks/1.0

The body is an event object:

{
  "id": "5b1c6e2a-9d47-4f83-a1b0-3c2e8f7d4a91",
  "idempotency_key": "5b1c6e2a-9d47-4f83-a1b0-3c2e8f7d4a91",
  "object": "event",
  "type": "reward.pending",
  "data": {
    "reward_id": "8f3a2b1c-6d94-4e07-9a58-1b7c4d2e9f30",
    "referral_id": "2c9d4e7f-1a63-48b5-8e02-6f3a9b1d5c74",
    "campaign_id": "7e1f8a3b-4c26-49d0-b591-0d8e2f6a3c15",
    "recipient": "referrer",
    "method": "free_access",
    "config": {
      "method": "free_access",
      "recipient": "referrer",
      "free_access_duration": { "count": 1, "unit": "month" }
    },
    "referral": {
      "id": "2c9d4e7f-1a63-48b5-8e02-6f3a9b1d5c74",
      "referred_user_external_id": "friend_123"
    },
    "referrer": {
      "id": "a4d43a94-3382-4ed9-b3db-83e1453a6c7d",
      "external_id": "member_456"
    }
  },
  "livemode": false,
  "created_at": "2026-07-09T18:12:04.000Z"
}
  • id — unique per event (a UUID) and identical on every retry; use it to deduplicate.
  • idempotency_key — identical to id, provided explicitly for receivers that persist a named idempotency field.
  • type — one entry from the event catalog.
  • data — the event's payload, specific to its type. For reward.pending, it includes the reward config plus the referrer and referred user's stable external IDs; use those IDs to grant access in your own backend.
  • livemodefalse for test-mode events. Test-mode activity fires real webhooks, flagged this way, so you can exercise your handler end-to-end before launch.
  • created_at — ISO 8601 timestamp of when the event occurred.

Verify signatures

The Invitebase-Signature header carries a Unix timestamp (t) and an HMAC-SHA256 signature (v1). The signed payload is the timestamp, a dot, and the raw request body: {t}.{raw_body}. Verify every delivery:

  1. Parse t and v1 from the header.
  2. Compute HMAC-SHA256(signing_secret, "{t}.{raw_body}") over the raw body bytes — do not re-serialize parsed JSON.
  3. Compare against v1 with a constant-time comparison.
  4. Reject if t is more than 5 minutes from now. The timestamp is inside the signed payload, so an attacker cannot replay an old delivery with a fresh timestamp.

Whatever you verify with, always use the raw body bytes — read the body as text before any JSON parsing or framework body middleware touches it.

With the SDK helper

If your backend runs JavaScript, @invitebase/js ships a verifier that does all four steps — constant-time comparison, the 5-minute replay window, and support for multiple secrets during a secret roll. It is a separate server-only subpath (@invitebase/js/webhooks) built on Web Crypto, so it works on Node 20+, edge runtimes, Deno, and Bun, and never ends up in your browser bundle:

app/api/invitebase/webhooks/route.ts
import { verifyWebhookSignature } from '@invitebase/js/webhooks';

export async function POST(req: Request) {
  const payload = await req.text(); // raw body — read before parsing JSON
  const ok = await verifyWebhookSignature({
    payload,
    header: req.headers.get('Invitebase-Signature'),
    secret: process.env.INVITEBASE_WEBHOOK_SECRET!, // whsec_…
  });
  if (!ok) return new Response('invalid signature', { status: 400 });

  const event = JSON.parse(payload);
  await enqueue(event); // your job queue — respond before heavy work
  return new Response(null, { status: 200 });
}

verifyWebhookSignature returns a boolean and never throws on malformed input. Options: toleranceSeconds overrides the replay window (default 300), and secret accepts an array of whsec_… values so old and new secrets both verify while you rotate.

By hand

The scheme is a few lines in any language with an HMAC primitive:

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

const TOLERANCE_SECONDS = 300;

export function verifySignature(
  rawBody: string,          // the exact request body, unparsed
  signatureHeader: string,  // the Invitebase-Signature header
  secret: string,           // whsec_… from endpoint creation
): boolean {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=") as [string, string]),
  );
  const timestamp = Number(parts.t);
  if (!parts.v1 || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return false;
  }
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
server.ts
import express from "express";
import { verifySignature } from "./verify";

const app = express();

app.post(
  "/invitebase/webhooks",
  express.raw({ type: "application/json" }), // keep the raw body
  (req, res) => {
    const ok = verifySignature(
      req.body.toString("utf8"),
      req.header("Invitebase-Signature") ?? "",
      process.env.INVITEBASE_WEBHOOK_SECRET!,
    );
    if (!ok) return res.status(400).send("invalid signature");

    const event = JSON.parse(req.body.toString("utf8"));
    res.sendStatus(200); // acknowledge first, process async
    queue.enqueue(event); // your job queue
  },
);

Roll a signing secret

Roll an endpoint's secret from Developer tools → Webhooks. Each roll bumps the endpoint's signing version and displays the new whsec_… once — the endpoint itself (URL, subscriptions, delivery history) is unchanged.

The cutover is immediate: every delivery attempt after the roll is signed with the new secret only. That includes retries of events that first fired before the roll — each attempt is re-signed with the endpoint's current secret, so nothing stays pinned to the old one. The only gap to manage is your own deploy, since the new secret exists only after you roll:

  1. Roll in the dashboard and copy the new secret.
  2. Deploy your verifier with both secrets — the SDK helper takes an array: secret: [NEW_SECRET, OLD_SECRET]. By hand, accept the delivery if it verifies against either.
  3. Drop the old secret in your next deploy.

If you skip the overlap, deliveries that fail verification during the gap are retried automatically with the current secret — events arrive late, not never. Treat a roll as revocation: once your handler drops the old secret, anything still signed with it is rejected.

Event catalog

Event typeFires when
referral.creditedA signup is attributed to a referrer. Linked is not validated — no billing or rewards yet. See Linked vs validated.
referral.validatedA referral passes all qualification gates and fraud checks. The billing and reward trigger.
referral.rejectedA referral is rejected — by the fraud rules on the pass that would have validated it, or by a reversal afterward.
referral.expiredThe qualification window passed before the gates were met.
reward.pendingA reward was created off a validated referral and is ready for you to act on. For app grants this is your cue to grant the reward in your backend and confirm it — see In-app reward webhooks.
reward.held_for_reviewA reward was held back from fulfillment. It fires no further reward events until it is approved or rejected. Automatic fraud decisions never hold — they approve or reject outright.
reward.fulfilledThe reward completed fulfillment (app grant confirmed, gift card delivered, payout sent).
reward.failedA payout failed after retries (money rewards only). Inspect the reward and retry or resolve manually.
payout.availableA cash/gift-card payout became available for a referrer to claim.
balance.lowYour prefunded balance crossed its configured low-balance threshold. Top up to keep payouts flowing.

Subscribe each endpoint only to the types it handles; you can add more later. Unhandled types you still receive should be acknowledged with a 2xx and ignored.

At-least-once delivery and ordering

Delivery is at least once: the same event can arrive more than once (for example when a retry crosses a slow 2xx). Make handlers idempotent — record processed event ids (or the Invitebase-Delivery header, which is unique per endpoint/event pair and stable across retries) and skip duplicates.

Ordering is not guaranteed. referral.validated can arrive before a delayed referral.credited retry. Do not build state machines off arrival order; use the event's created_at timestamp, or treat the webhook as a signal and fetch the current resource from the API for truth.

Retry schedule

If your endpoint returns a non-2xx status, times out, or is unreachable, Invitebase retries with exponential backoff for about 3 days:

AttemptDelay after previous
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours
7–10every 12–24 hours, up to ~72 hours total

In test mode the schedule is compressed — 3 retries over 15 minutes — so you can watch failures resolve without waiting hours.

After the final attempt the delivery is marked dead. Dead deliveries stay in the delivery log and can be redelivered manually. Sustained failures also trigger the failure alerts on your endpoint, so a broken receiver never fails silently.

Responding to deliveries

  • Return 2xx fast. Anything in the 200–299 range counts as delivered; everything else (including 3xx redirects) is a failure. Invitebase times out a delivery after 10 seconds.
  • Acknowledge before you process. Verify the signature, persist or enqueue the event, respond, then do the real work in a background job. Slow handlers cause timeouts, which cause retries, which cause duplicates.
  • Do not require authentication beyond the signature. The signature is the authentication. Endpoints behind basic auth or IP allowlists tend to break silently during infra changes.

Test deliveries and redelivery

From Developer tools → Webhooks in the dashboard you can:

  • Test-fire any event type at an endpoint with a realistic fixture payload, signed with the endpoint's real secret.
  • Inspect every delivery — full attempt history, request payload, response status and body, and timing.
  • Resend any delivery with one click, including dead ones after you fix your handler.
  • Simulate a full lifecycle — one click fires the whole sequence (referral.creditedreferral.validatedreward.pendingreward.fulfilled) against your endpoint using your own campaign data, so you can test multi-event handling instead of one event at a time.

Local development

Your development machine is not reachable from the internet, so forward deliveries to localhost.

The invitebase CLI is planned but not yet available. Until it ships, use a tunnel (below).

When available, the CLI streams test-mode deliveries to a local URL with signatures intact:

invitebase listen --forward-to http://localhost:3000/invitebase/webhooks
invitebase trigger referral.validated

Today, use a tunnel such as ngrok or Cloudflare Tunnel:

ngrok http 3000
# then register https://<subdomain>.ngrok.app/invitebase/webhooks
# as a test-mode endpoint in the dashboard

Signature verification works unchanged through a tunnel — the raw body and header pass through intact. Pair the tunnel with the dashboard's test-fire and lifecycle simulator to exercise your handler without a live app.

What's next

On this page