Integrate
Reward webhooks
When a reward is approved, Flow Affiliate asks your app to credit it with a signed POST. Your app credits the user once, answers with a receipt, and both sides can reconcile every reward.
How delivery works
Rewards are what your own users earn in friend-referral programs: the friend who invited someone, or the new user themselves. Flow Affiliate decides the amount; your app credits it. When a reward is approved, Flow Affiliate sends a POST to your app's reward webhook URL:
- Your endpoint verifies the signature.
- It credits the user, at most once per
idempotencyKey, in a transaction. - It answers
2xxwith a receipt. The reward is marked delivered and the receipt is stored with it.
Anything else (a timeout, a 5xx, a network error) is retried. There are two URLs per app, one per environment; send yours to your Flow Group contact, who enters them in the app's settings. Sandbox rewards only ever go to the sandbox URL, so a production receiver can never be handed test money.
Event types
reward.approvedcreditdata.amount in data.currency.reward.reversedclaw backThe event that earned a reward you already credited was undone (a refund, a cancelled subscription, one of your own reversal events). Take the value back under your own rules, and say in your answer whether you could.
The message
{
"id": "0e7c2b9a-5f1d-4a8e-b3c6-9d2f1e0a7b54",
"type": "reward.approved",
"environment": "production",
"idempotencyKey": "reward.approved:7d1f9e2c-3b4a-4c5d-8e6f-0a1b2c3d4e5f",
"createdAt": "2026-11-09T12:31:09.412Z",
"data": {
"rewardId": "7d1f9e2c-3b4a-4c5d-8e6f-0a1b2c3d4e5f",
"appUserId": "u_77",
"beneficiary": "referrer",
"amount": 200,
"currency": "USD",
"programId": "5b8f…",
"campaignId": "c41a…",
"conversionId": "9e02…",
"referredAppUserId": "u_1042",
"referrerAppUserId": "u_77",
"rule": { "id": "a7c3…", "name": "Friend joins", "triggerEvent": "purchase.completed" },
"sourceEventId": "order:o-77120",
"kind": "referral",
"breakdown": [{ "kind": "base", "amount": 200, "label": "Friend joins" }],
"ladder": null,
"gift": null
}
}Envelope
idstringrequiredtypestringrequiredreward.approved or reward.reversed.environmentstringrequiredproduction or sandbox. Sandbox messages are test traffic and must never move real value.idempotencyKeystringrequired<type>:<rewardId>. Credit at most once per key, and answer every repeat with the same receipt.
createdAtstringrequireddata
rewardIdstringrequiredappUserIdstringrequiredreward.reversed: who was credited).beneficiarystringrequiredreferrer (the friend who invited) or referred_user (the new user).amountintegerrequiredcurrencystringrequiredprogramId, campaignId, conversionIdstringrequiredreferredAppUserIdstringreferrerAppUserIdstringruleobjectrequiredid, name and triggerEvent of the rule that paid.sourceEventIdstringeventId of the event that earned the reward.kindstringreferral (a rule payout), ladder_bonus (the referrer reached a ladder tier, see ladder) or gift (a welcome gift from a gift link, see gift). Treat a missing kind as referral.
breakdownarrayHow amount was reached, line by line (base, boost, gift_funding…). The lines add up to amount; show them to the user if you like.
reason, reversalEventIdstringreward.reversed only: why, and Flow Affiliate's id of the event that caused it.Fields may be added to data over time. Ignore the ones you don't know.
Verifying signatures
Deliveries are signed exactly like your own requests, with an app key of the same environment:
x-fg-keyheaderx-fg-timestampheaderx-fg-signatureheader<timestamp>.<raw body> under that key's secret.x-fg-eventheaderreward.approved or reward.reversed.x-fg-deliveryheaderx-fg-idempotency-keyheaderidempotencyKey in the body.Verify against the raw body, before any JSON parsing, and reject timestamps more than 300 seconds old.
import express from 'express';
import { createFlowGrowth, rewardReceipt } from '@flow/growth-sdk';
const fg = createFlowGrowth({ baseUrl: 'https://api.flowgroup.se', keyId: process.env.FG_KEY_ID!, secret: process.env.FG_SECRET! });
const app = express();
app.post('/webhooks/flow', express.raw({ type: 'application/json' }), async (req, res) => {
const verified = fg.verifyWebhook(req.body, req.headers); // the raw Buffer, not parsed JSON
if (!verified.ok) return res.status(401).json({ error: verified.error.code });
const { idempotencyKey, type, data } = verified.event;
const receiptId = await db.transaction(async (tx) => {
const done = await tx.receipts.find(idempotencyKey);
if (done) return done.receiptId; // a repeat: same answer, no second credit
const id = type === 'reward.approved' ? await tx.wallet.credit(data.appUserId, data.amount, data.currency) : await tx.wallet.clawBack(data.appUserId, data.amount);
await tx.receipts.save(idempotencyKey, id);
return id;
});
res.json(rewardReceipt(receiptId)); // { received: true, receiptId }
});import { createHmac, timingSafeEqual } from 'node:crypto';
/** rawBody: the request body exactly as received, as a Buffer. */
export function verifyFlowSignature(rawBody: Buffer, headers: Record<string, string | undefined>, secret: string) {
const timestamp = headers['x-fg-timestamp'];
const signature = headers['x-fg-signature'];
if (!timestamp || !signature || !/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/i.test(signature)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return timingSafeEqual(Buffer.from(signature, 'hex'), expected);
}Answering
Answer 2xx within 10 seconds, once the credit is durably recorded. The body is optional but recommended:
{ "received": true, "receiptId": "wallet-tx-90311" }receiptIdstringYour id for the credit. It is stored on the reward and shown in the delivery log, so both sides can reconcile.
clawedBackbooleanFor reward.reversed: answer false when you could not take the full value back (the user already spent it). Finance then sees that the money stayed with the user.
Retries
A delivery that times out, gets a network error, a redirect or any status other than 2xx is retried with exponential backoff. With the default settings that is up to 8 attempts over roughly an hour (30 seconds, then 1, 2, 4, 8, 16 and 32 minutes between attempts). After the last one the message is dead-lettered and waits in the app's delivery log, where it can be retried by hand once the receiver is fixed.
Two answers stop retries at once: 400 and 422 mean you looked at the message and refused it, so sending the same bytes again cannot help. Use them only for messages you will never accept.
Your URL must use https and resolve to a public address. Redirects are not followed: a signed message only ever goes to the URL you configured.