Skip to content
IntegrateWebhooks

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:

  1. Your endpoint verifies the signature.
  2. It credits the user, at most once per idempotencyKey, in a transaction.
  3. It answers 2xx with 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.approvedcredit
Credit the user with data.amount in data.currency.
reward.reversedclaw back

The 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

idstringrequired
The message id. The same across every retry of this message.
typestringrequired
reward.approved or reward.reversed.
environmentstringrequired
production 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.

createdAtstringrequireddate-time
When this message was built.

data

rewardIdstringrequireduuid
Flow Affiliate's id of the reward.
appUserIdstringrequired
The user to credit (for reward.reversed: who was credited).
beneficiarystringrequired
referrer (the friend who invited) or referred_user (the new user).
amountintegerrequired
Minor units. Never a float.
currencystringrequired
ISO 4217.
programId, campaignId, conversionIdstringrequireduuid
Where the reward comes from, for your records and for support.
referredAppUserIdstringnullable
The new user the reward is about.
referrerAppUserIdstringnullable
The user who invited them, when the referrer is one of your users.
ruleobjectrequired
id, name and triggerEvent of the rule that paid.
sourceEventIdstringnullable
Your eventId of the event that earned the reward.
kindstring

referral (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.

breakdownarray

How 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, reversalEventIdstring
reward.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-keyheader
The key id that signed the message.
x-fg-timestampheader
Unix time in seconds.
x-fg-signatureheader
Hex HMAC-SHA256 of <timestamp>.<raw body> under that key's secret.
x-fg-eventheader
The message type, reward.approved or reward.reversed.
x-fg-deliveryheader
The id of this attempt. Each retry has a new one; quote it to support.
x-fg-idempotency-keyheader
The same value as idempotencyKey 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 }
});

Answering

Answer 2xx within 10 seconds, once the credit is durably recorded. The body is optional but recommended:

{ "received": true, "receiptId": "wallet-tx-90311" }
receiptIdstring≤ 200 chars

Your id for the credit. It is stored on the reward and shown in the delivery log, so both sides can reconcile.

clawedBackboolean

For 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.