Integrate
Events
Events are how your backend tells Flow Affiliate what users do. Send them signed, server to server, with your own stable ids: retries are safe and nothing is ever counted twice.
An event
{
"eventId": "order:o-77120",
"type": "purchase.completed",
"occurredAt": "2026-11-09T12:31:07Z",
"attributionKey": "fga_k7qxa2mpw4ht9bvc3rnd8ezj",
"amount": 250,
"currency": "USD",
"metadata": { "isFirst": true }
}Send it to POST /v1/events as the body of a signed request. The registration is special: it is the one event that attributes a user, so it carries both your appUserId and the attribution key from your ask. After that, the key alone says who an event is about.
Fields
eventIdstringrequiredYour stable id for this occurrence, derived from your own data: registered:<user id>, order:<order id>. Flow Affiliate deduplicates on it.
typestringrequiredA dotted event type such as user.registered. Any type is accepted and stored; payouts pay only on types in your app's event catalog.
occurredAtstringrequiredWhen it happened in your system, ISO 8601 with Z or an offset. Rules and attribution windows use this time, not the time we received it.
appUserIdstringYour pseudonymous user id. Never an email address or a phone number. Required on user.registered, and on any event that carries no attributionKey.
attributionKeystringThe key from POST /v1/attributions. On user.registered, with appUserId, it confirms the referral: the key is bound to that user and its touch wins the attribution. On any event after that it may stand in for appUserId.
clickIdstringfg_click from the landing URL, universal link or Play install referrer. A value that is not a UUID is dropped rather than rejected, so a mangled parameter never makes the event undeliverable.
affiliateCodestringThe publisher’s code: fg_ref from the same places, or a code the user typed. On user.registered it counts as a claim of that code. It used to be called referralCode; an event that still sends that name is refused with 400 renamed_field, so the code is never dropped unnoticed.
amountintegerMinor units (cents). Percentage rules pay a share of it.
currencystringThree upper-case letters. Send it whenever you send amount; the SDK refuses one without the other.
reversesEventIdstringThe eventId of an earlier event this one undoes. See reversals.
versionintegerThe version of your event's shape, if you ever change it.
metadataobjectFree-form details. Rules can match on them (isFirst: true for a first-time-only payout), and a few keys mean something to the engine: country (ISO alpha-2, picks the market's campaign in friend-referral programs), platform (ios or android), segment (which boosts apply) and legacyReferral: true (the user already has a friend referrer in your own scheme).
Event types
Every app has an event catalog: the standard events, which are the same for every app, and your own events for what is unique to your app. A payout pays on one counting event from the catalog.
Standard events
user.registeredattributionSomeone signed up. Send it once per new user, with the attributionKey from your ask (or clickId and/or affiliateCode). This is the only event that attributes a user.
purchase.completedamount requiredamount.subscription.startedamount requiredpurchase.refundedreversalpurchase.completed.subscription.cancelledreversalsubscription.started.Your own events
Anything else your app reports, you add as your own event in the program: Tracking → Server events, or step 2 while you set the program up. Each one has:
keystringrequiredWhat your server sends as type: lower-case letters, digits and _, parts separated by dots, for example level.completed. It can't be changed once the event exists.
namesv + enrequiredkindcounts | reversesrequiredA counting event can be paid on. A reversal undoes an earlier event, and names which event it undoes, for example level.revoked undoes level.completed.
valuesoptionalamount, and the metadata keys it sends.Your own events are never deleted. An event you no longer use is archived: it leaves the lists until you restore it. While a payout pays on an event, it can't be archived or turned into a reversal.
Unknown events
A type that is in neither list is still accepted and stored, but nothing pays on it. It shows as an unknown event on the program's page, where one click adds it as your own event.
Signals
Two types are read by Flow Affiliate itself and are not in the catalog:
user.verifiedreferral.claimedThe user has a friend referrer in your own scheme; metadata.referrerAppUserId names them.
Batching
POST /v1/events takes either one event as the body, or up to 500 in { "events": [ … ] }. The response lists every event you sent, in order:
{
"accepted": [
{ "eventId": "order:o-77120", "status": "accepted" },
{ "eventId": "order:o-77121", "status": "duplicate" },
{ "eventId": "order:o-77122", "status": "rejected", "reason": "key_not_registered" }
]
}The status is 202 Accepted: events are stored first and processed moments later. What the engine did with each one (attributed, paid a rule, ignored and why) shows in your app's event feed.
An event is rejected only when its attributionKey can't be used: unknown_key, key_expired, key_bound_to_other_user, key_not_registered (only user.registered with an appUserId binds a key) or key_declined. A rejected event is not stored, so the same eventId can be sent again once fixed.
Idempotency
Every event is unique by app, eventId and environment. Send an event again (a retry after a timeout, a replay after a crash) and it comes back as duplicate; nothing is processed or paid twice. One exception keeps retries useful: if the first delivery was stored but never finished processing, the repeat processes it again.
So derive eventId from your own data and keep it stable. A random id per send defeats deduplication, which is why the SDK only generates one when you omit it, and why you should only omit it for events you never send twice.
Reversals
When something is undone (a refund, a cancelled subscription, one of your own reversal events), send an event with reversesEventId set to the eventId of the original, for the same user (the same appUserId or attributionKey):
{
"eventId": "refund:r-5531",
"type": "purchase.refunded",
"occurredAt": "2026-11-12T08:02:44Z",
"appUserId": "u_1042",
"reversesEventId": "order:o-77120"
}Commissions and rewards the original event earned are reversed while they are still pending or approved. Money that was already settled or paid is never clawed back automatically: it is flagged for review instead. A reward your app already credited is flagged too, and your app gets reward.reversed so it can take the value back under its own rules. A reversal that names an unknown event, or one belonging to another user, is ignored with reversed_event_unknown or reversal_user_mismatch.
reversesEventId always undoes the event it names. If your integration puts the id in metadata.reversesEventId instead, it is read only for types the catalog calls reversals: the standard ones and your own.
Signing requests
Every request to the App API carries three headers:
x-fg-keyheaderrequiredfg_test_… in the sandbox, fg_live_… in production.x-fg-timestampheaderrequiredx-fg-signatureheaderrequired<timestamp>.<raw body>.Sign the exact bytes you send: serialize the body once, sign that string, and send that same string. A GET request signs an empty body, so the signed string is just the timestamp and a dot.
import { createHmac } from 'node:crypto';
const body = JSON.stringify(event); // send exactly this string
const timestamp = Math.floor(Date.now() / 1000);
const signature = createHmac('sha256', process.env.FG_SECRET!)
.update(`${timestamp}.${body}`)
.digest('hex');
await fetch('https://api.flowgroup.se/v1/events', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-fg-key': process.env.FG_KEY_ID!,
'x-fg-timestamp': String(timestamp),
'x-fg-signature': signature,
},
body,
});TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$FG_SECRET" | sed 's/^.*= //')
curl https://api.flowgroup.se/v1/events \
-H "content-type: application/json" \
-H "x-fg-key: $FG_KEY_ID" \
-H "x-fg-timestamp: $TS" \
-H "x-fg-signature: $SIG" \
--data-raw "$BODY"Errors and retries
Errors share one shape:
{ "error": { "code": "invalid_signature", "message": "invalid_signature" } }400 validation_errordo not retryThe body does not match the schema. details says which field and why. Fix the event; resending it unchanged fails the same way.
401 missing_signaturedo not retryx-fg-* headers is absent.401 invalid_timestampdo not retryx-fg-timestamp is not a Unix time in seconds.401 stale_timestampretry, re-signed401 invalid_keydo not retry401 invalid_signaturedo not retry429 too_many_attemptsretry laterToo many requests in a minute for one key (1,200 by default). Wait for Retry-After seconds; batch events to send fewer requests.
5xxretryRetry with backoff, with the same eventId and a fresh signature. Events already stored come back as duplicate.
The Node SDK does all of this for you: it retries network errors, timeouts, 429 and 5xx with exponential backoff and a fresh signature, and returns errors as values ({ ok: false, error }) instead of throwing into your request handler.