Guides
Attribution for apps
Three calls from your backend: ask about what your app caught, confirm the user once you know they are new, then report what they do with the key. Every call is signed and server to server.
Your app knows who is new. Flow Affiliate knows which link or code brought them, and what that earns. An app integrates in three calls, all from your backend, signed with your app key:
- Ask with
POST /v1/attributionsas soon as the app has caught something. The answer comes at once, with an attribution key. - Confirm with
user.registeredcarrying the key, once you know the user is new. - Report every later event with the key.
- 1Your appFirst open: a link, the App Clip, Branch params, the Play referrer or an install match; or a code typed at sign-up
- Your app → Your backendWhat it caught, as it caught it, with an install id
- 2Your backend → Flow AffiliateAsk:
POST /v1/attributions - Flow Affiliate → Your backendYes with an
attributionKey, or no with a reason - 3Your backend → Flow AffiliateConfirm:
user.registeredwith the key, once the user is new - 4Your backend → Flow AffiliateReport: every later event, with the key
Three rules keep the integration small:
- Ask as early as you have something. Confirm when you know. Asking binds nothing and pays nothing; only the confirmation does.
- Send each meaningful event once, with its money fields. Flow Affiliate decides what pays, so changing an offer never needs a code change on your side.
- Only send events for users who have a key. Flow Affiliate never needs data about anyone else.
The examples use the Node SDK, or this shell helper, which signs a body and sends it:
fg() { # fg POST /v1/events '<json>'
local ts sig
ts=$(date +%s)
sig=$(printf '%s.%s' "$ts" "$3" | openssl dgst -sha256 -hmac "$FG_SECRET" | sed 's/^.*= //')
curl -s -X "$1" "https://api.flowgroup.se$2" -H "content-type: application/json" \
-H "x-fg-key: $FG_KEY_ID" -H "x-fg-timestamp: $ts" -H "x-fg-signature: $sig" --data-raw "$3"
}Flow Affiliate never attributes an install on its own. Your app catches what it was installed or opened with and hands it to your backend as it is, with no mapping code. In an Expo app,
@flow/expodoes the catching:import AsyncStorage from '@react-native-async-storage/async-storage'; import { createAffiliateCatcher, AffiliateProvider, useAffiliate } from '@flow/expo'; export const affiliates = createAffiliateCatcher({ storage: AsyncStorage }); // at the root: <AffiliateProvider catcher={affiliates}>…</AffiliateProvider> const { affiliate } = useAffiliate(); useEffect(() => { // exactly the body of POST /v1/attributions: your backend forwards it as it is if (affiliate) void api.post('/affiliate/attribution', affiliate); }, [affiliate]);It tries every way the code can travel, most reliable first, and keeps the first catch of the install:
- the link that opened the app, as
url: a universal link or app link carryingfg_clickandfg_ref, or a Flow Affiliate linkhttps://go.affiliate.yflow.io/a/<code>; - on iOS, what your App Clip left for the full app, as
codeandclickId; - the referring params your deep-link SDK gives you (Branch, AppsFlyer, Adjust), handed to
ingestParamsand sent asparams: the whole object. Flow Affiliate findsfg_refandfg_clickin its keys and in a Flow Affiliate link among its values, such as Branch's~referring_link; - the Play install referrer on Android, read at first launch, as
codeandclickId; - with install matching on, a probable match of the first launch to a recent click from the same network and kind of phone (on iPhone also from another network, when the phone agrees in every signal), as
codeandclickIdwithsource: 'install_match'andconfidence: 'probable'.
What none of these catches, as an iPhone install from the App Store often is, is left to the code field: the person types the publisher's code, and your backend asks with it as
code.Flow Affiliate links and data are separate from your app's own referral system: only
fg_click,fg_refand Flow Affiliate links are read, never your own keys such ascode. Catching the invite works every time on Android with Google Play. On iPhone an installed app opened from the link, or an App Clip if you choose one, carries the click; install matching makes a probable match; the code field catches the rest. Links into your app shows how the invite travels on each platform and what to set up for each way.The SDK also makes a random install id at first launch and keeps it on the device. Sent with the ask, it makes asking again from the same install safe: the answer is the same key. Without the SDK, send the same fields yourself.
The secret never goes into the app. The app talks to your backend; your backend talks to Flow Affiliate.
- the link that opened the app, as
Ask as soon as your backend has something, before sign-up if you like. The answer comes at once, so you can show "Invited by Anna" on the sign-up screen.
fg POST /v1/attributions '{"params":{"+clicked_branch_link":true,"+match_guaranteed":false,"fg_ref":"k7qxa2mp","~referring_link":"https://example.app.link/AbC123"},"installId":"5b1d0c9e-2f4a-4b8c-9d7e-6f5a4b3c2d1e","platform":"ios"}' # {"attributable":true,"attributionKey":"fga_k7qxa2mpw4ht9bvc3rnd8ezj","status":"pending", # "partner":{"displayName":"Anna Berg"},"program":{"id":"…","name":"Invite friends"}, # "inviteeBonus":null,"expiresAt":"2026-12-09T09:30:00.000Z"}const res = await fg.createAttribution({ params: branchParams, installId, platform: 'ios' }); if (res.ok && res.data.attributable) { await saveForInstall(installId, res.data.attributionKey); showInvitedBy(res.data.partner.displayName); }An install match is asked about the same way, as it came:
{ "code": "k7qxa2mp", "clickId": "…", "source": "install_match", "confidence": "probable", "installId": "…" }. ItspublisherNameis for your sign-up screen and is ignored here. A probable match is attributed and paid like any other once you confirm the user; theconfidenceis recorded. See install matching.When nothing was caught, ask with the code the person typed at sign-up:
{ "code": "k7qxa2mp", "installId": "…" }. See the code field.A yes carries the
attributionKey. Keep it with the install or the sign-up session until you know whether the user is new. It expires after the program's attribution window (expiresAt).inviteeBonusis the welcome gift the link carries for the new user, when it carries one.A no is an answer too, with status 200 and a
reason:no_affiliatenothing to go onNothing you sent names a code or a click, as for an install that came from no link.unknown_codenot yoursNo code like this belongs to your app. A typed code may belong to your own referral scheme instead.unknown_clicknot yoursNo code was sent, and the click id is not a click on one of your links.code_inactiveswitched offThe code was switched off or has expired, or its publisher can no longer be paid.program_closedclosedThe program the code belongs to is not running.already_attributedtakenOnly when you sentappUserId: that user is already attributed.- Confirm
Confirm the new user
Whether the user is new is your decision, by your own rules: someone who had an account before, or reinstalls, is not new. Once you know, send
user.registeredwith the key and the newappUserId. That binds the key to the user and attributes them to that referral, whatever else is known about them, and pays the sign-up rule if the offer has one.fg POST /v1/events '{"eventId":"registered:u_1042","type":"user.registered","occurredAt":"2026-11-09T09:30:00Z","appUserId":"u_1042","attributionKey":"fga_k7qxa2mpw4ht9bvc3rnd8ezj"}' # {"accepted":[{"eventId":"registered:u_1042","status":"accepted"}]}await fg.track({ type: 'user.registered', eventId: `registered:${user.id}`, appUserId: user.id, attributionKey });If the user is not new, don't send the key. It expires unused. If you want the publisher to see why rather than a silent expiry, decline it:
POST /v1/attributions/<key>/declinewith areason(existing_user,duplicate,suspected_fraudorother).An event whose key can't be used is not stored. It comes back
rejectedwith a reason, so you can fix it and send the sameeventIdagain:unknown_keyNo key like it belongs to your app in this environment.key_expiredThe key was never confirmed, and its window has closed.key_bound_to_other_userThe key was confirmed for anotherappUserId.key_not_registeredThe key is not confirmed yet: onlyuser.registeredwith anappUserIdbinds it.key_declinedYou declined the key. Every later event carries the key.
appUserIdmay come along too, or be left out: the key stands for the user it was confirmed for. The type is a standard event or one of your own from the app's event catalog, such aslevel.completedin a game. The offer's rules decide what pays:- a once-only rule pays on the first matching event. Later events of that type are counted and shown in the event feed, but pay nothing more;
- a recurring rule pays on every matching event.
fg POST /v1/events '{"eventId":"level:u_8F2K:12","type":"level.completed","occurredAt":"2026-11-10T14:02:00Z","attributionKey":"fga_k7qxa2mpw4ht9bvc3rnd8ezj","metadata":{"level":12}}'For purchases, subscriptions or what the user earns in your app, send
amountin minor units (cents) andcurrency. Amounts add up per user and publisher, and pay only through a revenue-share rule: it pays its percentage of each amount, and a milestone rule pays once the user's total reaches a threshold. An amount in another currency than the rule's pays nothing.fg POST /v1/events '{"eventId":"order:o-77120","type":"purchase.completed","occurredAt":"2026-11-12T08:00:00Z","attributionKey":"fga_k7qxa2mpw4ht9bvc3rnd8ezj","amount":1999,"currency":"USD"}'Use your own transaction id as
eventId:registered:<user id>,order:<order id>. Flow Affiliate deduplicates on app,eventIdand environment, so sending an event again answersduplicateand changes nothing. Retry any timeout or5xxwith the sameeventId.To undo something (a refund, a cancelled subscription), send an event with
reversesEventIdset to the original'seventId. See reversals.
Example: Branch at first open
Here is how an app that uses Branch does it. The app gets Branch's params at first open and posts them to the backend after login. The backend asks with the params and the install id, then confirms straight away, because it knows the user registered a moment ago. Nothing is held back waiting for the attribution to settle.
// first open: keep what Branch hands over, as it is
branch.subscribe(({ params }) => {
if (params?.['+is_first_session']) storage.set('referringParams', JSON.stringify(params));
});
// after sign-up or login: hand it to the backend once
const params = storage.get('referringParams');
if (params) {
await api.post('/affiliate/attribution', { params: JSON.parse(params), installId: storage.get('installId') });
storage.delete('referringParams');
}app.post('/affiliate/attribution', requireUser, async (req, res) => {
const { params, installId } = req.body;
const ask = await fg.createAttribution({ params, installId, platform: req.user.platform });
if (!ask.ok || !ask.data.attributable) return res.json({ invitedBy: null });
const { attributionKey, partner } = ask.data;
if (await isNewUser(req.user)) {
// registered a moment ago: confirm now
await fg.track({ type: 'user.registered', eventId: `registered:${req.user.id}`, appUserId: req.user.id, attributionKey });
await users.update(req.user.id, { attributionKey });
} else {
await fg.declineAttribution(attributionKey, { reason: 'existing_user' });
}
res.json({ invitedBy: partner.displayName });
});Optionally, a code typed into your own referral code field can be checked the same way. Ask with the code; when it is not a Flow Affiliate code, your own referral scheme handles it as before:
const ask = await fg.createAttribution({ code: form.referralCode, installId });
if (ask.ok && !ask.data.attributable && ask.data.reason === 'unknown_code') {
await ownReferralScheme(user, form.referralCode);
}From then on every event goes out once, with the key and its money fields:
await fg.track({
type: 'purchase.completed',
eventId: `order:${order.id}`,
attributionKey: user.attributionKey,
amount: order.total,
currency: 'USD',
});Other ways in
Both earlier paths still work:
- Claim a code for a user who already has an account:
POST /v1/affiliates/claimwith theappUserIdand thecode. A registration that arrived earlier without a code is attributed again when the claim lands. The answer carries the claim'sattributionKey. - One call: send
user.registeredwithaffiliateCodeand/orclickIdand no key. Simpler, but there is no answer to show: an unknown code leaves the user unattributed (no_touchin the event feed).