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
- A referral validates. Invitebase creates a Reward in status
pendingand, once the fraud decision approves it on the same pass, sends areward.pendingwebhook — 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.) - Your handler verifies the
Invitebase-Signatureheader, applies the grant in your own system, and returns a2xx. Failed deliveries retry for about 3 days and can be redelivered from the dashboard. - Your backend confirms the grant with
POST /v1/rewards/{reward_id}/fulfill. The reward moves tofulfilled— 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
{
"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:
method | What to grant |
|---|---|
free_access | Extend 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_app | Grant 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:
- Verify the signature; return
401on failure. - Insert
idempotency_key(or the reward'sreward_id) into a table with a unique constraint. On conflict, return200immediately — you have already processed it. - Grant, confirm via
/fulfill, return200. Return2xxonly after durable processing; anything else (or a timeout) triggers a retry. - Keep handlers fast — enqueue slow work and return.
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);import hashlib, hmac, os, time
import httpx
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SECRET = os.environ["INVITEBASE_WEBHOOK_SECRET"]
API_KEY = os.environ["INVITEBASE_SECRET_KEY"]
API = "https://api.invitebase.com"
AUTH = {"Authorization": f"Bearer {API_KEY}"}
TOLERANCE_S = 300
@app.post("/webhooks/invitebase")
async def invitebase_webhook(request: Request, invitebase_signature: str = Header(...)):
body = await request.body() # raw body — required for verification
parts = dict(p.split("=", 1) for p in invitebase_signature.split(","))
expected = hmac.new(SECRET.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < TOLERANCE_S
if not fresh or not hmac.compare_digest(parts.get("v1", ""), expected):
raise HTTPException(status_code=401, detail="bad signature")
event = await request.json()
if event["type"] != "reward.pending":
return {"ok": True}
data = event["data"]
# Idempotency: INSERT ... ON CONFLICT DO NOTHING on idempotency_key
if not await db.record_webhook_once(event["idempotency_key"]):
return {"ok": True} # duplicate delivery
async with httpx.AsyncClient(base_url=API, headers=AUTH) as invitebase:
# These are the stable IDs your app supplied to Invitebase.
if data["recipient"] == "referred_user":
user_id = data["referral"]["referred_user_external_id"]
else:
user_id = data["referrer"]["external_id"]
if not user_id:
raise HTTPException(status_code=400, detail="missing recipient identity")
cfg = data["config"]
if data["method"] == "free_access":
await db.extend_entitlement(user_id, cfg["free_access_duration"])
elif data["method"] == "in_app":
await db.grant(user_id, cfg["in_app_payload"])
# Confirm the grant — idempotent, safe to repeat on redelivery.
await invitebase.post(f"/v1/rewards/{data['reward_id']}/fulfill")
return {"ok": True}Delivery semantics
| Property | Behavior |
|---|---|
| Reward state | A 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 |
| Ordering | Not guaranteed — key logic on the event payload, not arrival order |
| Retries | Exponential backoff, roughly 3 days, until a 2xx is returned; dead deliveries can be redelivered from the dashboard |
| Duplicates | Possible (at-least-once) — dedupe on the event id |
| Test mode | Real deliveries with livemode: false — handle them, never bill for them |
| Fraud rejection | A 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.pendingat 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.