Skip to content
IntegrateEvents

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

eventIdstringrequired≤ 200 chars

Your stable id for this occurrence, derived from your own data: registered:<user id>, order:<order id>. Flow Affiliate deduplicates on it.

typestringrequired≤ 100 chars

A dotted event type such as user.registered. Any type is accepted and stored; payouts pay only on types in your app's event catalog.

occurredAtstringrequireddate-time

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

appUserIdstring≤ 200 chars

Your pseudonymous user id. Never an email address or a phone number. Required on user.registered, and on any event that carries no attributionKey.

attributionKeystring≤ 100 charsnullable

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

clickIdstringuuidnullable

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

affiliateCodestring≤ 100 charsnullable

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

amountinteger≥ 0nullable

Minor units (cents). Percentage rules pay a share of it.

currencystringISO 4217nullable

Three upper-case letters. Send it whenever you send amount; the SDK refuses one without the other.

reversesEventIdstring≤ 200 charsnullable

The eventId of an earlier event this one undoes. See reversals.

versioninteger≥ 1default 1

The version of your event's shape, if you ever change it.

metadataobject

Free-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.registeredattribution

Someone 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 required
A completed purchase. Percentage payouts pay a share of amount.
subscription.startedamount required
A subscription started, for example when a trial turns into a paid plan.
purchase.refundedreversal
Undoes purchase.completed.
subscription.cancelledreversal
Undoes subscription.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:

keystringrequired

What 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 + enrequired
What you, Flow Group and publishers read, in Swedish and English.
kindcounts | reversesrequired

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

valuesoptional
Whether the event carries an amount, 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.verified
The user passed your verification, for payouts that count verified users only.
referral.claimed

The 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-keyheaderrequired
Your key id: fg_test_… in the sandbox, fg_live_… in production.
x-fg-timestampheaderrequired
Unix time in seconds. Rejected when more than 300 seconds from our clock.
x-fg-signatureheaderrequired
Lower-case hex HMAC-SHA256, keyed with your secret, over <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,
});

Errors and retries

Errors share one shape:

{ "error": { "code": "invalid_signature", "message": "invalid_signature" } }
400 validation_errordo not retry

The 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 retry
One of the three x-fg-* headers is absent.
401 invalid_timestampdo not retry
x-fg-timestamp is not a Unix time in seconds.
401 stale_timestampretry, re-signed
The timestamp is more than 300 seconds from our clock. Check the server's clock (NTP).
401 invalid_keydo not retry
The key id is unknown or revoked, or the app is archived.
401 invalid_signaturedo not retry
The HMAC does not match. You signed different bytes than you sent, or used the wrong secret.
429 too_many_attemptsretry later

Too many requests in a minute for one key (1,200 by default). Wait for Retry-After seconds; batch events to send fewer requests.

5xxretry

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