In-App Reward Webhooks

Grant in-app rewards from your backend with signed reward webhooks

App-grant rewards — free access, skins, feature unlocks, in-app currency — are things only your system can hand out. This guide covers the backend half: Invitebase sends a signed reward.pending webhook when a reward is ready, your server grants it in your own system, and confirms the grant back so the reward record reads fulfilled.

Invitebase records the delivery and fulfillment state, while your backend owns the actual entitlement, expiry, and access enforcement. A failed delivery never loses a reward — deliveries retry, and every reward stays queryable through the Rewards API. See Rewards and payouts for how app grants compare to money rails.

The flow

  1. A referral validates. Invitebase creates a Reward in status pending and, once the fraud decision approves it on the same pass, sends a reward.pending webhook — the signal that the reward is ready for you to grant. (A referral the fraud rules reject sends nothing: its rewards are rejected with it.)
  2. Your handler verifies the Invitebase-Signature header, applies the grant in your own system, and returns a 2xx. Failed deliveries retry for about 3 days and can be redelivered from the dashboard.
  3. Your backend confirms the grant with POST /v1/rewards/{reward_id}/fulfill. The reward moves to fulfilled — the ack is idempotent, so confirming twice is safe.

Register your endpoint

In the dashboard under Developer tools → Webhooks, add your endpoint URL and subscribe it to reward.pending (at minimum). The signing secret is shown once — store it as an environment variable. Endpoints are registered per mode: create one with test keys first.

Or via the API:

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/webhooks/invitebase",
    "enabled_events": ["reward.pending", "referral.rejected"]
  }'

Verify the signature

Every delivery is HMAC-SHA256 signed. The Invitebase-Signature header carries a timestamp and signature:

Invitebase-Signature: t=1720512000,v1=f2c9a1e8b4d7...

Compute HMAC-SHA256 over "{t}.{raw request body}" with your signing secret, compare in constant time, and reject anything older than the tolerance (5 minutes) to block replays. Verify against the raw body — parsing and re-serializing JSON will break the signature. Full spec and helpers: Webhooks.

Handle the payload

reward.pending delivery
{
  "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"
}

Route on data.method:

methodWhat to grant
free_accessExtend the recipient's entitlement by config.free_access_duration{ "count": 3, "unit": "month" }, where unit is day, week, or month. Only delivery: "webhook" free access reaches you this way; code deliveries are handed out by Invitebase.
in_appGrant whatever config.in_app_payload describes (skin, feature, currency)

The payload carries only stable IDs from your system, never profile data. Use data.referral.referred_user_external_id for the friend or data.referrer.external_id for the referrer, depending on data.recipient. From there the grant is a plain lookup in your own users table.

Confirm the grant

Once the grant is durably applied, acknowledge it so the reward record reads fulfilled — in the dashboard, in the SDK, and in the API:

curl -X POST https://api.invitebase.com/v1/rewards/8f3a2b1c-6d94-4e07-9a58-1b7c4d2e9f30/fulfill \
  -H "Authorization: Bearer $INVITEBASE_SECRET_KEY"

The ack is idempotent — confirming an already-fulfilled reward returns it unchanged, so it is safe inside a retried handler. Only app-grant rewards (in_app, free_access) are confirmed this way; money-rail rewards are fulfilled by the payout provider, and acking one returns 409. Full endpoint semantics: Fulfill a reward.

Write an idempotent handler

Delivery is at least once with exponential backoff for about 3 days — your handler will see duplicates and must treat them as no-ops. The reliable pattern:

  1. Verify the signature; return 401 on failure.
  2. Insert idempotency_key (or the reward's reward_id) into a table with a unique constraint. On conflict, return 200 immediately — you have already processed it.
  3. Grant, confirm via /fulfill, return 200. Return 2xx only after durable processing; anything else (or a timeout) triggers a retry.
  4. Keep handlers fast — enqueue slow work and return.
server.ts (Express)
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.INVITEBASE_WEBHOOK_SECRET!;
const API_KEY = process.env.INVITEBASE_SECRET_KEY!;
const TOLERANCE_S = 300;

app.post(
  '/webhooks/invitebase',
  express.raw({ type: 'application/json' }), // raw body — required for verification
  async (req, res) => {
    const header = req.header('Invitebase-Signature') ?? '';
    const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${parts.t}.${req.body}`)
      .digest('hex');

    const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < TOLERANCE_S;
    const valid =
      parts.v1?.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
    if (!fresh || !valid) return res.status(401).send('bad signature');

    const event = JSON.parse(req.body.toString());
    if (event.type !== 'reward.pending') return res.sendStatus(200);

    const { idempotency_key } = event;
    const { reward_id, recipient, method, config, referral, referrer } = event.data;

    // Idempotency: unique index on processed_webhooks.idempotency_key
    const inserted = await db.processedWebhooks.insertIgnore({ idempotencyKey: idempotency_key });
    if (!inserted) return res.sendStatus(200); // duplicate delivery

    // These are the stable IDs your app supplied to Invitebase.
    const userId =
      recipient === 'referred_user'
        ? referral.referred_user_external_id
        : referrer.external_id;
    if (!userId) return res.status(400).send('missing recipient identity');

    if (method === 'free_access') {
      await db.users.extendEntitlement(userId, config.free_access_duration);
    } else if (method === 'in_app') {
      await db.users.grant(userId, config.in_app_payload);
    }

    // Confirm the grant — idempotent, safe to repeat on redelivery.
    await fetch(`https://api.invitebase.com/v1/rewards/${reward_id}/fulfill`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${API_KEY}` },
    });

    res.sendStatus(200);
  }
);

app.listen(3000);

Delivery semantics

PropertyBehavior
Reward stateA failed delivery never loses a reward — the record stays pending, retries keep coming, and you can always list pending rewards from the API as a backstop
OrderingNot guaranteed — key logic on the event payload, not arrival order
RetriesExponential backoff, roughly 3 days, until a 2xx is returned; dead deliveries can be redelivered from the dashboard
DuplicatesPossible (at-least-once) — dedupe on the event id
Test modeReal deliveries with livemode: false — handle them, never bill for them
Fraud rejectionA referral the fraud rules reject never fires reward.pending — its rewards are rejected with it, on the pass that would have validated it

Test it

  • Send test delivery on the endpoint's page fires a signed sample reward.pending at your URL.
  • The webhook scenario simulator replays a full referral lifecycle — linked → validated → reward — against your endpoint using your own campaign data, so you can test multi-event handling in one click.
  • Every delivery (payload, response code, attempts) is inspectable under Developer tools → Webhooks, with one-click redeliver.

What's next

On this page