Tracking integration

Contract version 1.0.0-alpha.1. Everything on this page is the public surface: the click URL, four endpoints your app calls, and one URL you paste into your purchase provider's dashboard.

Two hard rules. Read these before anything else.

1. Call /t/i BEFORE your app brings up a VPN tunnel, proxy or any other traffic redirection. If a tunnel is already up, the address we observe is the tunnel's exit and not the subscriber's. Both probabilistic matching probes are keyed on that address, so every install from your app becomes organic: the install is recorded, nothing errors, every status endpoint stays green, and the campaign that produced the user is never credited. There is no way to repair it afterwards.

2. Never apply a paywall or a product from a response whose signature did not verify. A signed response that fails verification is not a transport glitch; it is somebody choosing what your users are offered. On a verification failure: render your own built-in paywall from your own catalogue, treat the decision as show:false, count one sig_verify_failed — and still persist the attribution block if it said terminal:true. Section 5 gives the exact rule, and it is the one place in this document where a response is half-obeyed on purpose.

1The whole flow on one page

Two launch paths. Everything else in this document is detail inside one of them.

COLD START, first ever launch
-----------------------------
  app starts
    |
    +- start the splash timer (floor 600 ms)
    |
    +- in PARALLEL, not in sequence:
    |     +- POST /t/i      signed, 1200 ms timeout, one retry with 400 ms
    |     |                 jitter, 2500 ms total budget
    |     +- load the store catalogue from your purchase provider
    |
    +- the first of these sets the `decided` latch, exactly once:
    |     +- a verified /t/i response
    |     +- the 2500 ms budget expiring   -> safe default show:false
    |     +- a cached decision still inside its expires_at
    |
    +- splash has been up for at least 600 ms? wait until it has
    |
    +- branch ONCE:
          paywall.show == true
            AND every products[] entry resolved in the catalogue
            AND paywall.template is one this build can render
              -> mount the paywall, send paywall_impression
          anything else
              -> go to the main screen, send no_products if that was the reason


WARM START, every launch after
------------------------------
  app starts
    |
    +- read the last known good decision out of local storage
    |     expires_at still in the future (monotonic clock)?
    |        -> print it IMMEDIATELY, no network wait at all
    |
    +- go to the paywall or the main screen from that cached decision
    |
    +- in the BACKGROUND, off the critical path:
          GET /t/d  -> replace the cached decision for next launch
          attribution still `pending`? retry /t/i on the ladder in section 4

Three properties of that diagram are the ones that cost money when they are dropped, and each has its own section:

a budget rather than a timer racing the request (section 4);

screen the user is already using (section 4);

whose buy button cannot complete a purchase must never be mounted (section 7).

The endpoints

MethodPathSignedWhat it does
GET/t/c/{campaign}noRecords the click and answers 302 to the store or to a landing page. Never fails closed: with its cache unreachable the redirect is still served and the row is buffered.
GET/t/cnoThe same operation for links that carry the campaign in the query string. It exists so that a link already live in a traffic partner's panel keeps working with nothing changed but the hostname.
POST/t/iyesOne signed call on first launch. Answers the attribution result, the opaque install token, and the paywall decision for the launch placement.
GET/t/dyesLater cold starts and placements other than launch. It carries neither the referrer nor the device tuple, so it can neither open an install nor claim a click.
POST/t/eyesOne event or a batch of up to 50, each idempotent on the client's own client_event_id.
POST/t/wh/{provider}/{app}noWhere RevenueCat, Adapty or Stripe deliver subscription events. You configure this URL once in the provider's dashboard; your app never calls it.

/t/c is not called by your app. It is the URL a traffic partner puts in their link, it answers 302, and your app only ever sees its consequence: an install referrer on Android, or a matched device tuple. Section 10 is its reference.

2The v:1 request and response, field by field

Every table in this section is generated from the server's own wire types at build time. If a member is renamed in the server, the table changes here; it is never retyped.

POST /t/i — request body

Content type application/json. Every member below is inside the HMAC, and that is not an implementation detail: an unsigned build.channel lets anyone relabel a test build as production, an unsigned available_products lets them choose which of your products they are offered, and an unsigned sdk lets them read a store catalogue that is not theirs.

MemberTypeAlways presentMeaning
app_idstringyesYour application id, as issued to you.
app_versionstringyesYour build's version string. Paywall rules may be bounded by it, so send the real one.
client_noncestringyes128 bits from the platform CSPRNG, hex or base64url. You choose this; you never choose the install token. Keep the same value for every retry of the same first launch -- that is what makes every rung of the retry ladder land on one install instead of opening several.
buildobjectyesThe build channel block. See below -- it is mandatory, and sending the wrong value is how a developer's own launches get charged against a live campaign.
fingerprintobjectnoThe four normalised device fields. Absent is legal and costs matching confidence -- we then parse our own request User-Agent instead, which caps confidence at 55. Normalise them YOURSELF with the rules in section 7a, byte for byte.
install_referrerstringnoThe raw Play install-referrer string, exactly as InstallReferrerClient handed it over.
click_idstringnoThe exact channel for web and for any client that captured the referrer itself.
subscriber_refstringnoThe app's own user identifier.
available_productsarray of stringnoThe product ids the client ACTUALLY resolved from the store catalogue -- not the ones it hopes exist.
supported_templatesarray of stringnoThe paywall templates this build can render.
sdkstringnoThe purchase SDK this build speaks: "revenuecat", "adapty" or "stripe".

build

MemberTypeAlways presentMeaning
channelstringyesproduction, testflight, internal or dev. Anything other than production is forced to organic and marked as a test install: no credit, no partner payout, no appearance in a report. The empty string is not production either.

channel is a closed set of four: production, testflight, internal, dev. An unrecognised value is 422, and the empty string is not production -- an install that failed to say which build it is gets the test treatment, because the other failure (paying for a developer's own install) is the expensive one. Anything other than production is forced to organic and marked as a test install: no credit, no partner payout, no appearance in a report. Send the true value. Sending production from a debug build is how a developer's own launches are charged against a live campaign.

fingerprint

MemberTypeAlways presentMeaning
platformstringyesios, android or web. Lowercase. Section 7a.
os_majorstringyesThe LEADING component of the OS version only: 17.5.1 becomes 17. Section 7a.
device_familystringyesA closed enum: iphone, ipad, android_phone, android_tablet, desktop, other. A raw model string never goes here. Section 7a.
langstringyesThe primary subtag of the DEVICE language, lowercase. Both - and _ are cut. Section 7a.

Absent is legal and costs matching confidence — we then parse our own request User-Agent instead, which caps confidence at 55. The four values must be normalised by you, with the rules in section 7a, byte for byte.

POST /t/i — response body

Content type application/json, Cache-Control: no-store. The signature is in X-A2L-Sig with its key id in X-A2L-Key, over the exact response bytes.

There is deliberately no sig member and no key_id member inside this body.
A signature inside a body cannot sign that body, and any proxy that
re-encodes the JSON would break it.
MemberTypeAlways presentMeaning
vintegeryesEnvelope version. 1 today. Treat an unknown value as a reason to use your cached decision, not as an error.
decision_idstringyesOpaque id for THIS decision. Carry it on every funnel event (section 8); it is what joins an impression to the decision that produced it.
expires_atstring (RFC 3339)yesWhen this decision stops being usable, RFC 3339 UTC. Default lifetime 6 hours. Compare it against a MONOTONIC clock, never the device wall clock. Section 4.
degradedbooleanyesDegraded means a dependency was unavailable and the decision was made on less than the full picture.
attributionobjectyesHow this install was resolved. See below.
identityobjectyesWho your purchase SDK should be. Always present; its members are optional. See below and section 7b.
install_tokenstringyesOpaque. Store it in Keychain or Keystore and send it in X-A2L-Install from then on. It is not a JWT, has no readable structure, and must not be parsed.
paywallobjectnoThe launch decision.
splitobjectnoPresent ONLY when the winning rule carried one.

attribution

MemberTypeAlways presentMeaning
kindstringyesWhat this install was resolved to. Treat it as an opaque label and branch on is_paid and terminal instead; the set of kinds grows.
terminalbooleanyesTerminal says whether the client may stop asking.
confidenceintegeryes0-100. Informational: it is not a threshold you apply.
is_paidbooleanyesDerived from Kind, NOT from a subscription state.
click_idstringnoThe click this install was matched to. Absent on an organic install.
campaign_idstringnoAbsent on an organic install.
publisher_idstringnoAbsent on an organic install.
sub_publisher_idstringnoComposed as publisher/sub. Absent on an organic install.
creative_tagstringnoThe creative identifier from the click. Absent on an organic install.
prelander_idstringnoThe landing-page template the matched click came through. Absent on an organic install. Present on a replay as well as on the first answer, so a second launch gets the same decision.

kind is one of play_referrer, click_id, fingerprint, ip_ua, organic, manual. The set grows — branch on is_paid and terminal, never on kind. confidence is one of 100, 85, 70, 55, 45, 0 today and is informational; it is not a threshold you apply.

identity

MemberTypeAlways presentMeaning
sdk_user_idstringnoThe id to bind your purchase SDK to. Absent on an organic install, and absent means stay anonymous -- do not substitute your own user id, your own device id, or an empty string. Section 7b.
set_sdk_attributesobjectnoAttributes to set on your purchase provider, verbatim, keys included. Absent on an organic install. No member is ever an address and none is a secret. Section 7b.

set_sdk_attributes is absent on an organic install. When present its keys are a2l_click_id, a2l_campaign_id, a2l_publisher_id, a2l_sub_publisher_id and a2l_creative_tag, each present only when it has a value. No member of this block is ever an address.

paywall

MemberTypeAlways presentMeaning
showbooleanyesThe only member you branch on first. true means offer a paywall, false means go to your main screen.
reasonstringnoPresent exactly when show is false. One of already_entitled, no_rule_match, default_off, product_unavailable, template_unsupported, degraded, killed. Treat an unknown value as show:false.
placementstringnoWhich placement this decision answers. Absent when show is false.
variantstringnoThe technical identifier of the screen configuration. Report it in your own analytics; do not display it.
templatestringnoThe screen your build renders. Already verified against your supported_templates[], so you will never receive one you did not declare. Section 6.
assetsobjectnoCopied verbatim out the configured variant. Opaque JSON object -- your template decides what its members mean. Two resolutions of one configuration produce identical bytes, so it is safe to key a cache on it.
dismissiblebooleannoSent even when it is false. A client that defaults a missing field to true turns a hard paywall into a soft one. Absent only when show is false.
max_impressions_per_dayintegernoA per-day cap you enforce on the device, in the device's own timezone. Absent means no cap; when present it is always at least 1, so a cap of zero is expressed as show:false and never as a 0 here.
productsarray of objectnoRequired and non-empty when show is true, absent when it is false. Every entry was verified present in your available_products[]. Section 7.

reason is present exactly when show is false, and is one of already_entitled, no_rule_match, default_off, product_unavailable, template_unsupported, degraded, killed. Treat an unknown value as show:false and go to your main screen.

dismissible is a boolean that is sent even when it is false. A client that receives no field and defaults to true turns a hard paywall into a soft one, and the two are then not comparable.

paywall.products[]

MemberTypeAlways presentMeaning
rolestringyesprimary, secondary or tertiary. Offer them in this order. A primary that does not resolve means the paywall is not offerable (section 7).
sdkstringyesWhich purchase provider this entry addresses: revenuecat, adapty or stripe. You only ever receive entries for the provider you declared.
idstringyesThe real store product id. Never an index into an offering -- use it to VERIFY whatever your provider's offering or placement resolved to.
offeringstringnoRevenueCat Offering key. Absent for the other providers.
packagestringnoRevenueCat Package identifier, e.g. $rc_annual. Absent for the other providers.
adapty_placementstringnoAdapty Placement id. Absent for the other providers.
adapty_variationstringnoAdapty paywall variation. Absent for the other providers.
stripe_price_idstringnoStripe Price id. Absent for the other providers.
has_intro_offerbooleannoThe PRODUCT's capability, not THIS user's eligibility.
intro_kindstringnoWhat the introductory offer is, e.g. free_trial. Present only when has_intro_offer is.
intro_period_isostringnoISO 8601 duration of the introductory offer: P3D, P1W, P1M. Present only when has_intro_offer is.
base_period_isostringyesISO 8601 duration of the regular subscription period: P1M, P1Y. Never hardcode this text -- section 12 (e).

role is primary, secondary or tertiary. id is the real store product id and never an index into an offering — the system this replaced addressed products by their position in a RevenueCat offering, and reordering the offering in a dashboard silently changed what every user was charged.

split

Present only when the winning configuration carries an experiment. Absent entirely otherwise — an empty arm on the wire would read as an experiment that assigned nobody, which is a different and far more alarming fact.

MemberTypeAlways presentMeaning
armstringyesWhich experiment arm this install landed in. Report it in your own analytics; do not change behaviour on it beyond what the decision already told you.
salt_usedstringyesThe salt that bucketed this install. Informational, for reproducing a bucket decision in a support request.

A working paid example

Against the sandbox identity from section 11, branch paid/nfl.

POST /t/i HTTP/1.1
Host: go.protect2lab.com
Content-Type: application/json
X-A2L-Key: sbx-k1
X-A2L-Ts: 1790000000
X-A2L-Nonce: 7f3a1c9e44b0d2185ca6e0f93b7d1c28
X-A2L-Sig: <64 lowercase hex characters -- see section 5>

{"app_id":"a2l-sandbox",
 "app_version":"0.0.0-sbx-paid-nfl",
 "client_nonce":"c0a6f2e1b3d4475a8e9f0112233445566",
 "build":{"channel":"production"},
 "fingerprint":{"platform":"ios","os_major":"17","device_family":"iphone","lang":"tr"},
 "sdk":"revenuecat",
 "available_products":["a2l.sandbox.annual.trial","a2l.sandbox.monthly"],
 "supported_templates":["video_hard","simple_list"]}
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
X-A2L-Key: sbx-k1
X-A2L-Sig: <64 lowercase hex characters -- verify this before obeying the body>

{"v":1,
 "decision_id":"dcn_4f9c2a18-7b31-4c5e-9d02-6a1f8e3b5c74",
 "expires_at":"2026-10-03T04:11:52Z",
 "degraded":false,
 "attribution":{"kind":"organic","terminal":true,"confidence":0,"is_paid":false},
 "identity":{},
 "install_token":"itk_9mQ2zXk4Lp7Rv0TbYc8NdWfA",
 "paywall":{"show":true,
            "placement":"launch",
            "variant":"sbx_paid_nfl",
            "template":"video_hard",
            "assets":{"headline":"sandbox","hero":"sandbox://hero","cta":"sandbox://cta"},
            "dismissible":false,
            "products":[{"role":"primary","sdk":"revenuecat","id":"a2l.sandbox.annual.trial",
                         "offering":"sbx_nfl","package":"$rc_annual",
                         "has_intro_offer":true,"intro_kind":"free_trial",
                         "intro_period_iso":"P3D","base_period_iso":"P1Y"},
                        {"role":"secondary","sdk":"revenuecat","id":"a2l.sandbox.monthly",
                         "offering":"sbx_nfl","package":"$rc_monthly",
                         "base_period_iso":"P1M"}]}}

Four things in that response are worth reading twice.

base64url characters. Both are opaque. Do not parse either one, and do not assume those lengths are stable.

the device wall clock: a user who moves their clock forward would otherwise discard a perfectly good decision on every launch.

called paid/nfl.** That is correct and deliberate. The sandbox chooses the PAYWALL answer; it does not fabricate an attribution, because there is no click behind the call. If you need a paid attribution end to end you need a real click, and section 11 says so again.

always present and only its members are optional.

A working organic example

Same identity, branch organic. Only the differences are shown.

{"app_id":"a2l-sandbox",
 "app_version":"0.0.0-sbx-organic",
 "client_nonce":"...",
 "build":{"channel":"production"},
 "sdk":"revenuecat",
 "available_products":["a2l.sandbox.monthly"],
 "supported_templates":["simple_list"]}
 "paywall":{"show":true,
            "placement":"launch",
            "variant":"sbx_organic",
            "template":"simple_list",
            "assets":{"headline":"sandbox","hero":"sandbox://hero","cta":"sandbox://cta"},
            "dismissible":true,
            "max_impressions_per_day":2,
            "products":[{"role":"primary","sdk":"revenuecat","id":"a2l.sandbox.monthly",
                         "offering":"sbx_organic","package":"$rc_monthly",
                         "base_period_iso":"P1M"}]}

Note that fingerprint is omitted here, which is legal, and that max_impressions_per_day is present — a cap you are expected to enforce on the device, per calendar day in the device's own timezone.

Status codes

GET /t/c/{campaign}

StatusMeaning
302Redirect to the store or to the configured landing page, with Cache-Control: no-store, private.
404No campaign with that identifier.

GET /t/c

StatusMeaning
302Redirect, exactly as the path form.
404No campaign with that identifier.

POST /t/i

StatusMeaning
200The attribution result, the install token and the launch decision. Signed in X-A2L-Sig over the exact response bytes.
401Signature missing, malformed or wrong.
429Too many requests.

GET /t/d

StatusMeaning
200A signed decision for the placement you asked for. Same paywall and split shape as /t/i.
401The signature is missing, malformed or wrong.

POST /t/e

StatusMeaning
200THROTTLED, and nothing was stored.
202Stored, or already stored.
401The signature is missing, malformed or wrong.
422The body will never parse or names something this system does not know: an unknown kind, an unknown placement, a missing client_event_id or decision_id, a detail that is not an object, an occurred_at that is not RFC 3339, or kind: decision_served, which the server writes.
503The event could not be stored -- nothing is wired, or the write failed.

POST /t/wh/{provider}/{app}

StatusMeaning
200Accepted.
401Provider signature or app webhook token did not verify.
404No application with that {app} slug.

Error bodies

Every 4xx and 5xx on these endpoints is {"error":"...","code":"...","request_id":"..."}. code is the only member you branch on. error is English prose for your logs and will change wording without notice. request_id is what to quote in a support request.

codeWhat it means, and what to do
bad_requestMalformed request -- unparseable JSON, or a parameter that cannot be read.
validation_failedThe request parsed but did not validate. See details.
unauthorizedNo credentials, bad credentials, or a session that has expired.
signature_invalidThe HMAC on a signed client request did not verify. The fix is a rotated key, not a fresh login.
clock_skewSignature timestamp outside the 300s window. See server_time.
not_foundNo such resource, or one the caller may not be told exists.
payload_too_largeThe request body is over the limit for this endpoint.
unsupported_media_typeThe content type is not one this endpoint accepts.
rate_limitedToo many requests. See Retry-After.
service_unavailableA dependency is down or the server is shedding load. Retry later.
internal_errorUnhandled server error. See request_id.

code is an open set: treat a value you do not recognise as a generic failure of the same status class, never as a success and never as a crash.

3The two hard rules, in full

Section 0 states them. This section is what each one costs and how to satisfy it.

Rule 1 — /t/i before the tunnel

Call /t/i before your app brings up a VPN tunnel, a proxy, a DNS redirection or anything else that changes the route your packets take.

Why it is a hard rule and not a recommendation: two of the four matching mechanisms are probabilistic, and both are keyed on the network address the install call arrives from. With a tunnel already up, that address belongs to the tunnel's exit node. Every install from your app then presents the same handful of addresses, the ambiguity gate refuses them all, and the answer is organic.

What makes it expensive is that nothing fails. The install row is written. The response is 200. The signature verifies. Your crash reporter is quiet, our status endpoints are green, and the only symptom is that a campaign you are buying produces organic installs. By the time anyone reads the numbers the clicks are outside the matching window and cannot be rematched.

How to satisfy it:

beside it. Not Promise.all, not a task group: before.

those later rungs are fine — the first call is the one that matters, because the address it arrived from is the one recorded against the install.

your app for the deterministic channels only (install referrer on Android, explicit click_id on both platforms). Matching will be exact or absent, which is honest; probabilistic matching through a tunnel is neither.

Rule 2 — nothing from an unverified response reaches a user

Verify X-A2L-Sig over the exact response bytes before you obey anything in the body. If it does not verify, refuse the paywall half and keep the attribution half. That split is deliberate and both halves matter.

VerifiedDid not verify
paywall.show, template, variant, assetsobeyignore. Treat as show:false and render your own built-in paywall if you have one
paywall.products[]obeyignore. Offer your own catalogue, never the response's
attribution.terminal:truepersistpersist anyway
install_tokenstorestore
counter—count one sig_verify_failed, report it on the next call that does verify

Why the paywall half is refused: forging it chooses what a user is offered and at what price. That is the only part of this response worth attacking.

Why the attribution half survives: it gives an attacker nothing worth forging and costs everything if it is dropped. A client that discards the whole response leaves its attribution state at pending, so the install never becomes terminal, the retry ladder runs forever, and the install ends up a permanent orphan — which is the most expensive client defect in this system, and the one the signature exists to prevent rather than to cause.

Partial obedience is not allowed. Keeping the template, or the variant, or one single product out of an unverified body is the same mistake as obeying all of it, because the attacker chooses which field to move.

The executable form of this rule, with its cases, lives in testdata/client_contract/response_signature.json. A reference client runs every case in CI, and each client library runs the same file. If you write your own client, run it too.

4Timeouts, the retry ladder, the latch, the splash window, the cache

These numbers are the client contract. They are not tuning suggestions: the system this replaced lost real revenue on every one of them, and the losses are named below.

Timeouts and the budget

Value
/t/i request timeout1200 ms
retries on first launchone, with 400 ms jitter
total budget for the whole launch decision2500 ms
/t/d request timeout1200 ms, no retry (it is off the critical path)

The budget is a budget, not a timer that races the request. The difference is the defect: the previous client installed a ten-second navigation timer after its initialisation promises resolved, and its network layer had no timeout at all — so a hung request never resolved, the timer was never installed, and nothing cancelled the navigation. A decision that arrived at 10.4 seconds found the user already on the main screen.

The decided latch

One latch, set exactly once, and every navigation goes through it.

decided = false

onDecision(d):           # from the network, the cache, or the budget expiring
    if decided: return   # <- this line is the whole rule
    decided = true
    navigate(d)

Race the three sources against each other and take the first; do not schedule navigation from more than one place. Every fixed-delay navigation timer is deleted — there are no exceptions, including the "safety" one.

A late answer after the latch is set is not navigated to. It updates the cached decision for the next launch and nothing else. Printing a paywall over a screen the user is already using produces uninstalls, not revenue.

The splash window

Value
floor600 ms
ceiling2500 ms

The floor exists because a splash that disappears in 90 ms reads as a flicker and the first screen appears to be the second one. The ceiling is the budget: when it expires, you navigate with the safe default and you do it at 2500 ms, not at 2501.

The retry ladder, for pending attribution

attribution.terminal is the server's statement that you may stop asking. Three client states, and only these three:

attribution_state ∈ { pending, decided_paid, decided_organic }

A terminal value is written ONLY from a response that said terminal:true. Never from your own reading of kind, never from "it has been a while", never from "is_paid was false so it must be organic". The ambiguity gate also answers terminal:true when it returns organic — not guessing is better than guessing — and that is still the server's statement, not yours.

While the state is pending, retry /t/i on every foreground, on this ladder:

0s → 5s → 30s → 5m → 30m → 2h → 6h → 24h → stop at 48 hours

Send the same client_nonce on every rung. That is what makes all of them land on one install instead of opening several. It stops at 48 hours because the matching window is 24 hours; retrying for a week asks a question that can no longer have an answer.

When a terminal decided_paid arrives late

Open the paywall at the next natural placement, never on top of the current screen. The placement for exactly this is late_attribution, and a paywall there is always dismissible:true: a hard wall on an app somebody has been using for two days produces refunds.

Also: when you navigate to the main screen on a degraded decision, do not consume your one-shot onboarding or splash-seen state. If you consume it, there is no natural moment left for the late-attributed user and the decision has nowhere to land.

The cache

Keep the last known good decision in local storage with its expires_at.

all, and refreshes in the background through /t/d;

6 hours, which is also the worst case for an operator's "stop this paywall" reaching your installed base;

install, a change of the device store country, or an app update that changes which templates you can render;

degraded one as if it were good.

The safe default

Tracker unreachable, no usable cache: show:false. Go to your main screen.

show:false is the safe direction because a missing paywall costs one conversion and an unwanted one costs a refund, a review and sometimes a store review flag. Never default to show:true, and never substitute your own hardcoded paywall for a decision you did not receive — unless the response arrived and failed its signature check, which is rule 2 and a different situation.

/t/i never answers 5xx. If you see one, treat it as a transport failure and use the ladder; do not treat it as a decision.

5Signing a request and verifying a response

Three client libraries and one server verifier have to produce identical bytes, so the canonical string is written down rather than described. The code in this section is complete and copyable; the rules under it are what you implement if your language is not one of the two shown.

The headers

HeaderRequiredMeaning
X-A2L-KeyyesKey id from app_client_keys. The client carries two -- current and next.
X-A2L-TsyesUnix seconds. Accepted window is 300s; outside it the answer is code: clock_skew.
X-A2L-NonceyesAt least 128 bits from the platform CSPRNG, hex or base64url. A repeat replays the previous response rather than failing.
X-A2L-SigyesLowercase hex HMAC-SHA256 over the canonical string. See A2LSignature.
X-A2L-InstallyesThe opaque install token minted by the server. A header, never a query parameter -- a query parameter ends up in access logs and in referrers. Covered by the signature.

The first four go on every signed request. X-A2L-Install goes on /t/d and /t/e only: /t/i is the call that MINTS the token, so on a first launch there is nothing to send — and on a /t/i retry you send the token you have and keep the same client_nonce. The "required" column above is the contract's declaration of the header itself, not a claim that all five apply to all four endpoints.

The request canonical string

Lifted directly out of the contract — this is the same text the server verifier is built from:

sig = HMAC-SHA256(secret,
      method + "\n" + path + "\n" + canonical_query + "\n" + ts + "\n" +
      nonce + "\n" + subject + "\n" + lowercase_hex(sha256(body_bytes)))

canonical_query, frozen

Every clause below has a test vector behind it, because every one of them is somewhere a URL library will disagree with another URL library.

1. split the raw query on & and drop empty segments; 2. split each segment on the first = only; 3. percent-decode key and value; 4. percent-re-encode every byte outside the RFC 3986 unreserved set (A-Z a-z 0-9 - . _ ~) using UPPERCASE hex; 5. sort bytewise on the pair (encoded_key, encoded_value); 6. keep repeated keys, all of them; 7. join as k=v with &.

No query is the empty string, and ? is never part of it.

Four specific traps, all of them vectors:

Most form-encoding helpers get this wrong for this purpose.

canonical string.

case is not normalised. Sign the bytes you are about to put on the wire.

ts is the decimal Unix seconds string exactly as sent — never reformatted, never zero-padded. The body hash is over the exact bytes sent; never re-serialise the object to compute it. An empty body hashes to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

subject

Never empty. It is client_nonce on the call that mints the install token, and install_token on every call after that one. The subject is inside the canonical string, so a decision signed for one install is not valid for another.

The response canonical string

Different from the request one, and shorter:

"a2l-response-v1" + "\n" + subject + "\n" + request_nonce + "\n" +
    lowercase_hex(sha256(response_bytes))

request_nonce is the X-A2L-Nonce you sent on the request this is the response to. That binding is what stops a captured cheap decision from being replayed against a later request. Compare the signature in constant time, over the raw bytes, never with == and never on a prefix.

TypeScript, complete

import { createHash, createHmac, timingSafeEqual } from "node:crypto";

const UNRESERVED = /[A-Za-z0-9\-._~]/;

function pctEncode(s: string): string {
  let out = "";
  for (const byte of new TextEncoder().encode(s)) {
    const ch = String.fromCharCode(byte);
    out += UNRESERVED.test(ch)
      ? ch
      : "%" + byte.toString(16).toUpperCase().padStart(2, "0");
  }
  return out;
}

// Decodes one percent-escaped token. A malformed escape stays literal:
// "%zz" -> "%zz", which re-encodes to "%25zz".
function pctDecode(s: string): string {
  const bytes: number[] = [];
  for (let i = 0; i < s.length; i++) {
    if (s[i] === "%" && /^[0-9A-Fa-f]{2}$/.test(s.slice(i + 1, i + 3))) {
      bytes.push(parseInt(s.slice(i + 1, i + 3), 16));
      i += 2;
    } else {
      for (const b of new TextEncoder().encode(s[i])) bytes.push(b);
    }
  }
  return new TextDecoder().decode(new Uint8Array(bytes));
}

export function canonicalQuery(rawQuery: string): string {
  const pairs: [string, string][] = [];
  for (const seg of rawQuery.split("&")) {
    if (seg === "") continue;                 // drop empty segments
    const i = seg.indexOf("=");               // first '=' only
    const k = i < 0 ? seg : seg.slice(0, i);
    const v = i < 0 ? "" : seg.slice(i + 1);  // a bare key becomes "key="
    pairs.push([pctEncode(pctDecode(k)), pctEncode(pctDecode(v))]);
  }
  pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
  return pairs.map(([k, v]) => `${k}=${v}`).join("&");
}

export function signRequest(opts: {
  /**
   * The secret issued with your key id, EXACTLY as issued.
   *
   * ★ IT IS AN OPAQUE STRING AND NOT HEX. The HMAC key is the secret's own
   * UTF-8 bytes; do not decode it, do not trim it, do not re-case it. Decoding
   * it as hex is the single commonest cause of a request that looks perfect and
   * answers `401 signature_invalid`.
   */
  secret: string;
  method: string;        // "POST", uppercase
  requestTarget: string; // "/t/i" or "/t/d?x=1" -- the RAW target
  ts: string;            // decimal Unix seconds, as you will send it
  nonce: string;         // as you will send it
  subject: string;       // client_nonce, or install_token
  body: Uint8Array;      // the EXACT bytes you will send
}): string {
  const q = opts.requestTarget.indexOf("?");
  const path = q < 0 ? opts.requestTarget : opts.requestTarget.slice(0, q);
  const raw = q < 0 ? "" : opts.requestTarget.slice(q + 1);

  const bodyHash = createHash("sha256").update(opts.body).digest("hex");
  const canonical = [
    opts.method, path, canonicalQuery(raw),
    opts.ts, opts.nonce, opts.subject, bodyHash,
  ].join("\n");

  return createHmac("sha256", Buffer.from(opts.secret, "utf8"))
    .update(canonical, "utf8")
    .digest("hex");
}

export function verifyResponse(opts: {
  secret: string;       // the same opaque secret, for the key id in X-A2L-Key
  subject: string;      // the same subject you signed the request with
  requestNonce: string; // the X-A2L-Nonce YOU sent
  body: Uint8Array;     // the EXACT response bytes, before any JSON.parse
  signature: string;    // X-A2L-Sig
}): boolean {
  const hash = createHash("sha256").update(opts.body).digest("hex");
  const canonical = ["a2l-response-v1", opts.subject, opts.requestNonce, hash].join("\n");
  const want = createHmac("sha256", Buffer.from(opts.secret, "utf8"))
    .update(canonical, "utf8")
    .digest();
  let got: Buffer;
  try {
    got = Buffer.from(opts.signature, "hex");
  } catch {
    return false;
  }
  return got.length === want.length && timingSafeEqual(got, want);
}

Three things that look like style and are not. The HMAC key is Buffer.from(secret, "utf8") and never Buffer.from(secret, "hex"): the issued secret is an opaque string, and Buffer.from does not throw on a string that is not hex -- it silently keeps the leading hex-looking prefix and drops the rest, so hex-decoding a secret produces a shorter key, a wrong signature, and a 401 with nothing in the request to look at. body is Uint8Array and not a string or an object, because the hash must be over the bytes you send — if you hash a re-serialisation you will match on your machine and fail on a device whose JSON writer orders keys differently. And verifyResponse takes the raw response bytes, so read them before you parse the JSON; a parsed-and- reprinted body is a different byte string.

Kotlin, the two primitives

private val UNRESERVED =
    ('A'..'Z').toSet() + ('a'..'z').toSet() + ('0'..'9').toSet() + setOf('-', '.', '_', '~')

fun pctEncode(s: String): String = buildString {
    for (b in s.toByteArray(Charsets.UTF_8)) {
        val c = b.toInt().toChar()
        if (c in UNRESERVED) append(c)
        else append('%').append("%02X".format(b.toInt() and 0xFF))
    }
}

fun hmacHex(secret: String, canonical: String): String {
    // ★ THE SECRET'S OWN BYTES. Not hex-decoded -- see signRequest above.
    val key = secret.toByteArray(Charsets.UTF_8)
    val mac = javax.crypto.Mac.getInstance("HmacSHA256")
    mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA256"))
    return mac.doFinal(canonical.toByteArray(Charsets.UTF_8))
        .joinToString("") { "%02x".format(it) }
}

Note %02X in the encoder and %02x in the signature: the canonical query uses uppercase hex and the output signature is lowercase. Getting them the same way round is the commonest single-character bug in this file.

Clock skew recovery

Outside the 300-second window the server answers 401 with code: clock_skew and its own server_time. Adopt the offset, persist it, and retry once. Do not loop: a device whose clock is wrong is wrong for hours, and a client that retries on every failure turns one bad clock into sustained load. Do not surface it to the user — a correctly implemented client recovers from this without anyone noticing.

Nonce, replay and rotation

base64url. A new one per request.

response, so a client that timed out and retried with the same nonce is safe. That is the correct retry: same nonce, same body, same client_nonce.

the server accepts both. Sign with current; when you are told to rotate, next becomes current and you receive a new next.

The vectors

testdata/hmac_vectors.json in the server repository holds eight request vectors and one response vector, and CI runs them against the Go verifier and against every client library. Ask us for the file and run it. The eight cases are: get_no_body, repeated_query_key, encoded_slash_in_path, plus_in_value, non_ascii_value, empty_value_and_bare_key, first_call_no_token, later_call_with_token.

Those names are the list of ways an implementation that "works" in testing fails in production. If your signer passes the first and the last and you stop there, the one that will break you is plus_in_value.

6Templates, assets, and the min_app_version contract

paywall.template

template is the identifier of a screen your build renders. It is not a closed set we publish: the operator names it when they configure a variant, and the names are whatever your team and theirs agreed on.

The contract is the other direction:

can actually render right now**;

whose template you did not declare is skipped, and the resolution continues as if that variant were not there;

renders it (and declares it), then have the operator configure a variant that uses it. The other order costs nothing — the variant is skipped until your build is out — but it looks like a broken configuration for a week.

Do not send a template you render badly. The failure it causes is the worst shape available: a screen mounts, has no working buy button, the user cannot purchase, and no error is raised anywhere. Declare it when it works.

paywall.assets

An opaque JSON object, copied verbatim out of the configured variant. Your template decides what its members mean; we never read them.

Three rules:

members, a member whose type changed: all of these must degrade, not crash. The object is edited in a panel by a human.

particular, nothing about price, period or trial length. Section 7 is why.

identical assets, so it is safe to key a cache on them.

min_app_version

A variant may carry a minimum build version. The resolution rule is specific and it is the one that is most often assumed wrong:

**A rule whose variant requires a newer build than yours is SKIPPED, and the
resolution CONTINUES to the next rule. It does not fall through to the
default, and it does not answer show:false.**

So a staged rollout behaves like this:

Your buildWhat happens
new enough for rule 1rule 1 wins
too old for rule 1, matches rule 2rule 2 wins — you still get a paywall
too old for every matching rulethe placement's default is used
no default configured eithershow:false, reason:"no_rule_match"

The server counts each skip (variant_version_skipped) precisely so that a staged rollout halving a campaign's conversion is distinguishable from bad traffic. Without that counter the wrong campaign gets paused.

Version strings, and fail-closed parsing

A version is one to four dot-separated groups of one to nine ASCII digits, and nothing else. No leading v, no whitespace, no suffix, no empty group. 1.2 and 1.2.0 compare equal.

A version string that does not parse is treated as older than every gate. That is deliberate and it is the cheap direction: an unparseable version costs a pre-release build the more aggressive paywall. The opposite polarity — a lenient parser reading 1.2.0-beta+meta as 1.2.0 — costs every hand-typed version string a gate it was never meant to pass.

Which means: send a parseable app_version. 1.4.2 parses. 1.4.2-rc3 does not, and that build will be treated as older than everything. If your release process produces suffixed versions, strip the suffix for this field and keep the full string wherever you report your own build id.

The sandbox selectors in section 11 (0.0.0-sbx-paid-nfl and the rest) are
deliberately unparseable for this reason. They can never be mistaken for a
real version, and a build that shipped one would be visibly wrong rather than
subtly mis-gated.

Placements

/t/i only ever asks for launch. The other four are reached through /t/d.

placementWhen you ask for it
launchFirst launch, from /t/i. You never ask for this one on /t/d.
onboarding_endThe last onboarding screen.
home_ctaA user tapped an upgrade control.
feature_gateA user reached something the subscription unlocks.
late_attributionAttribution became terminal after first launch. Always dismissible -- a hard wall on top of an app somebody has used for two days produces refunds, not revenue.

7Resolving products, and where the displayed price comes from

The one rule that is never negotiable

**The price, the currency, the period text and the trial terms a user SEES
always come from the purchase SDK on the device. Never from us.**

paywall.products[] tells you which product to offer and in what order. It carries no price, no currency and no store country, and those members must never be added — our idea of the price would disagree with the purchase sheet on the very next screen. The store's price is the store's: it carries the user's currency, their regional pricing, their tax, their promotional offers and their family-sharing state, and none of that is knowable from a server.

So the flow is always: we name the product, you look it up in the catalogue you already loaded, you render the store's price string and the store's eligibility.

Mapping products[] onto each provider

For every entry, in role order (primary first):

RevenueCat

entry.offering  -> the Offering key          (offerings.all[entry.offering])
entry.package   -> the Package identifier    ("$rc_annual", "$rc_monthly", ...)
entry.id        -> the StoreProduct identifier -- USE THIS TO VERIFY

Resolve the package, then assert that its storeProduct.identifier equals entry.id. If it does not, treat the entry as unresolved. This is the one check that catches an offering reordered or repointed in the dashboard — the system this replaced addressed products by their position in an offering, and a reorder silently changed what every user was charged while every report kept filing it under the old name.

Adapty

entry.adapty_placement -> the Placement id
entry.adapty_variation -> the paywall variation, when present
entry.id               -> the vendor product id -- USE THIS TO VERIFY

Stripe

entry.stripe_price_id -> the Price id
entry.id              -> the product id

When an entry does not resolve

not mount it. Go to your main screen and send one no_products event carrying the decision_id.

option and render the rest, as long as primary resolved.

A paywall whose buy button cannot complete a purchase is worse than no paywall: the user bounces, the impression is counted, and the conversion rate of that variant is poisoned for everyone reading it.

Intro offers and trial copy

has_intro_offer is the product's capability — not this user's eligibility. Eligibility is answered by the store, on the device, for that account. A user who already used the trial on another app in your group, or on this one two years ago, is not eligible and the store knows it.

Therefore:

introductory offer. Check with a presence test, not with !== null (see section 12, item c);

is. intro_period_iso and base_period_iso are ISO 8601 durations: P3D, P1W, P1M, P1Y;

only show it when the store says this user is eligible. Showing "3 days free" to somebody the store will charge immediately is a refund and, repeated, a store review problem.

The sdk member

You declare which provider you speak in the sdk field of the request: revenuecat, adapty or stripe. You only ever receive products[] entries for the provider you declared.

Declaring it is effectively mandatory. A price set may hold entries for several providers, and when it does and you declared none, nothing is offerable and the answer is show:false with reason:"no_products". We will not guess: handing a Stripe price id to a build that can only redeem an App Store product produces a buy button that cannot complete, and nothing anywhere would reveal it.

One app is not bound to one provider. A migration runs both for weeks and a web checkout is Stripe while the same app's iOS build is RevenueCat — which is exactly why this comes from the client and not from your app record.

7aThe device tuple normalisation contract

The fingerprint object is four fields and nothing else. They are compared as an all-or-nothing equality against the tuple the landing page declared, so a one-character disagreement between your normaliser and ours is not a degradation — it is a campaign whose installs all become organic while every component reports success.

This is why the rules are written out instead of described, and why there is a shared fixture.

The four fields

FieldRuleExamples
platformA closed set: ios, android, web. Lowercase.ios
os_majorThe leading component only. Separators are ., _ and -.17.5.1 → 17, 14.0.0 → 14, 15_7 → 15
device_familyA closed enum: iphone, ipad, android_phone, android_tablet, desktop, other.iPhone15,3 → iphone, SM-G991B → android_phone
langThe primary subtag of the DEVICE language, lowercase.tr-TR → tr, tr_TR → tr, tr-TR,tr;q=0.9,en → tr

Nothing else enters the comparison. No model string, no screen size, no timezone, no advertising identifier.

The rules, with the reason each one exists

os_major is the leading component. A point release must not split one device into two tuples, or a phone that updates between the click and the install never matches again. 17.5.1, 17.5 and 17 are all 17.

os_major keeps its value on iOS. The tuple is already deliberately low entropy; dropping a field widens the ambiguity gate and pushes more iOS installs to organic. Entropy is only reduced on the platform that has a deterministic channel of its own, which is Android.

device_family is the closed enum, always. A raw model string never reaches the comparison. An unrecognised value normalises to other — not to the empty string, and not to a guess.

lang cuts BOTH region separators. - and _. This is the single most common cause of a tuple that can never match, and the reason is that the two sides of one handset naturally produce different separators:

java.util.Locale, and Locale.toString() renders it with an underscore: tr_TR.

A normaliser that cuts only - writes lang="tr" for one side and lang="tr_tr" for the other, and the two can never meet.

lang is the DEVICE language. Never Bundle.preferredLocalizations (the languages your app ships), never your app's selected UI language, and never an in-app browser's own locale token (FBLC, ByteLocale).

The address is not in this object. It is not client-normalised: IPv4 whole, IPv6 truncated to the /64, done server side. Carriers rotate the host bits of one handset between the click and the install, so a /128 loses real installs.

Web and in-app browser cases

These only matter if you also run the landing page, but they are the cases that produce silent mismatches, so they are here.

Chrome's reduced Android User-Agent always says Android 10; K. Read os_major from navigator.userAgentData.getHighEntropyValues(["platformVersion"]). With no client hints available, write the empty string and not "10". Writing 10 makes every Chrome-Android click disagree with the native 14 and drops the Android fallback to zero.

iPadOS in desktop mode sends a Macintosh User-Agent. Turn Macintosh + navigator.maxTouchPoints > 1 into device_family:"ipad", platform:"ios", and in that branch read os_major from the Version/<n> token — never from Mac OS X 10_15_7, which has been constant on every Mac since 2020 and carries zero entropy.

Version/4.0 is a WebView marker, not an OS version. On Android, os_major comes only from the run of digits following the android token.

The shared fixture

testdata/fingerprint.json in the server repository holds the cases, and four implementations run it: the Go server, the landing-page JavaScript, the iOS library and the Android library. Fields are compared as strings, never as digests — a digest depends on a server-side secret and so cannot be a fixture.

Ask us for the file and run it in your own test suite. The cases are named for what they catch: chrome-reduced-android-ua, ipad-safari-desktop-mode, facebook-in-app-browser-ios, tiktok-webview-android.

Each case carries three expectations, and the third is the one worth understanding:

ExpectationWhat it asserts
declared_eq_nativethe landing page's tuple equals the device library's tuple, so a declared click matches its install
ua_parsed_eq_nativethe server's User-Agent-derived tuple equals the device's — almost never true, which is the point of having a landing page at all
lang_stripped_eq_ua_parsedthe device's tuple with lang emptied equals the server's User-Agent-derived one

The third exists because when no tuple was declared, the server parses the request User-Agent and gets lang="". That is a third shape, and it is why the install side tries two comparisons: your tuple, and your tuple with lang emptied. An implementation that tries one loses every campaign without a landing page to organic, silently.

7bPurchase SDK identity, and the order it must happen in

This section is three rules long and all three were learned from a shipped defect.

Rule 1 — logIn(click_id) once, and before any purchase attempt

Bind the identity once per install, from identity.sdk_user_id in the /t/i response, and bind it before the user can reach a buy button — not after the purchase, not on the success callback.

If the binding happens after the purchase, the receipt arrives at your purchase provider under an anonymous id, the provider's webhook reaches us with an identity we have never seen, and the subscription is booked as organic revenue. The purchase succeeded, your app is correct from the user's point of view, and the campaign that produced them is never credited.

identity.sdk_user_id is absent on an organic install. Absent means stay anonymous. Do not substitute your own user id, your own device id, or an empty string.

Rule 2 — never logIn on a device that already has a live subscription under another identity

Before calling the provider's identity method, read getCustomerInfo().activeSubscriptions. If it is non-empty and the current provider user id is not the one you are about to set, DO NOT call logIn. Write only the a2l_click_id attribute and stop.

The three devices this protects, all of them ordinary:

previous owner's or the previous install's provider state;

What an unconditional logIn does on any of those is merge entitlements across identities. The provider aliases the two users, and from then on one person's paid entitlement is visible to the other. It is not recoverable by calling logOut: the alias is a server-side fact at the provider.

The previous application in this group called the provider's logIn unconditionally, immediately after setting attributes. That is the exact code in section 13's deletion list.

// The shape of the check. Adapt to your provider's API, keep the order.
const info = await Purchases.getCustomerInfo();
const alreadyEntitled = (info?.activeSubscriptions?.length ?? 0) > 0;
const currentId = await Purchases.getAppUserID();

if (sdkUserId && !alreadyEntitled && currentId !== sdkUserId) {
  await Purchases.logIn(sdkUserId);              // the ONLY place this is called
  await Purchases.setAttributes({ a2l_click_id: clickId });
  await sendEvent({ kind: "identity_bound", detail: { logged_in: true } });
} else if (sdkUserId) {
  // Attribute only. Never merge.
  await Purchases.setAttributes({ a2l_click_id: clickId });
  await sendEvent({
    kind: "identity_bound",
    detail: { logged_in: false, refusal_reason: alreadyEntitled ? "already_entitled" : "same_id" },
  });
}

Report the outcome either way, with an identity_bound event (section 8). The refusal is the interesting one: it is how an operator can tell "this app never binds identity" from "this app correctly refused to merge on 4% of devices".

Rule 3 — set the attributes we hand you, verbatim

identity.set_sdk_attributes is a flat string map. Set every member, with the key exactly as given. The keys today are a2l_click_id, a2l_campaign_id, a2l_publisher_id, a2l_sub_publisher_id and a2l_creative_tag, each present only when it has a value, and the whole object is absent on an organic install.

Do not rename them to fit your own conventions, do not add your own, and do not reformat the values. They are what joins your provider's own dashboard to the report your operator reads; a renamed key produces two dashboards that disagree and no way to tell which is right.

No member of that object is ever an address, and none of them is a secret. They are safe to set.

8Funnel events

POST /t/e, signed, with the install token in X-A2L-Install. One event, or {"events":[ ... ]} carrying up to 50.

Every number on the paywall screen of your operator's panel has its divisor here. An app that cannot batch and therefore drops events is a reporting error nothing downstream can reveal — so batch.

The kinds

kindWhen your app sends it
paywall_impressionThe paywall is on screen and its buy button can complete a purchase. Not when you decide to show it.
paywall_dismissThe user closed a dismissible paywall without starting checkout.
checkout_startedYou called the store's purchase method. Carry product_id.
checkout_abandonedThe store sheet closed with no purchase and no error you can attribute.
purchase_client_ackThe store told your app the purchase succeeded. Informational only -- see section 8.
no_productsThe decision said show:true and none of its products[] resolved in the store catalogue. Send it and go to your main screen.
identity_boundYou called the purchase SDK's identity method for this install, or deliberately did not. Carry the outcome in detail.

Every kind carries decision_id, identity_bound included. It is what joins an event to the decision that produced it, and it is inside the signed /t/i or /t/d body.

purchase_client_ack is informational and never becomes revenue

Say it twice because it is the one that gets misread: nothing your app sends to /t/e ever becomes revenue, and purchase_client_ack is no exception.

Revenue and subscription state arrive from the provider webhook (/t/wh/{provider}/{app}) and from nowhere else. All seven kinds are registered server-side with revenue, cost and partner-notification all off, and that is a property of the registry rather than of the handler — there is no code path from this endpoint to a payout.

What purchase_client_ack is for: closing the funnel, and catching "the purchase succeeded on the device and no webhook ever arrived." That second one is a real provider failure mode and this event is the only way to see it. Send it, carry the store transaction id in detail, and expect nothing financial to follow.

decision_served is written by the server and refused here

There is an eighth kind, decision_served, and if you send it you get 422 with code: validation_failed. The server writes it when the resolved decision differs from the one the install was already handed, and it is the divisor of two rates. A client that could write it would double every denominator on the paywall screen.

The body

Per event: client_event_id (required), kind (required), decision_id (required), placement, product_id, subscriber_ref, app_version, ms_since_launch, occurred_at (RFC 3339), and detail — a JSON object carrying the members that belong to that one kind.

Stamp occurred_at when the event happens, and keep it. The idempotency key is (app_id, client_event_id, occurred_at). Resending the same bytes is free; a client that omits occurred_at and rebuilds the body gets now each time and double-counts its own events.

client_event_id is yours, and it must be stable across retries of the same event. A UUID generated at the moment the event occurs, stored with the event in your outbox, is the shape that works.

Three server behaviours to expect

record of its own decision keeps the whole funnel of that decision in one reporting cell. The answer reports what was actually stored.

minutes ahead. The storage is partitioned on it, so a device clock a week out would reach no partition at all. The answer reports the timestamp that was filed.

409. You may forget the event either way.

Answers, and exactly what to do with each

StatusShapeWhat you do
202{v, accepted, duplicate, kind, client_event_id, decision_id, placement, occurred_at} (single) or {v, accepted, duplicates, rejected:[...]} (batch)forget the accepted ones
200throttled:true, accepted falsyDROP the events. Do not retry
422ErrorDROP the event. The body will never parse
503ErrorKEEP the event and retry
401Errorsignature problem — see section 5. Keep the events

A batch is not atomic and never fails as a whole. Each event is its own write. Rejections come back per index: rejected: [{index, code, field?, retryable}]. index is the position in the array you sent, because the commonest rejection of all is an event with no client_event_id.

retryable is the only decision you have to make: false means the event will never be stored and must be dropped; true means the write failed and the event must be kept. An all-or-nothing batch would discard forty-nine good impressions over one typo, and a client unable to tell which one offended would resend all fifty forever.

{"events":[]} is refused with 422. There is nothing to store, nothing to report per index, and nothing a retry could change — an empty 202 would hide the bug in your outbox forever.

The budget

120 events per minute per install token, and going over it is 200, never 429. Over budget you get throttled:true and nothing is stored: accepted:0 in a batch, accepted:false in a single event.

It is deliberately not a 429. A 429 is a promise that retrying works, and the clients that believe it come back with the same events, harder. Drop them.

If the counter cannot be read at all, the limit is not applied and your events are accepted. A limiter failing closed would turn a cache blip into a silent funnel outage.

9Webhook setup, per provider

POST /t/wh/{provider}/{app} is where your purchase provider delivers subscription events. Your app never calls it. You paste the URL into the provider's dashboard once, per application.

https://api.scale2lab.com/t/wh/revenuecat/<your-app-slug>
https://api.scale2lab.com/t/wh/adapty/<your-app-slug>
https://api.scale2lab.com/t/wh/stripe/<your-app-slug>

{provider} is one of revenuecat, adapty, stripe. {app} is your application's slug, as issued. Both are required: the same URL with the wrong slug is a different application, and a delivery that verifies under the wrong one is worse than a rejected delivery.

This is also where all revenue comes from. Nothing your app sends to /t/e becomes revenue, including a successful purchase acknowledgement. If this URL is wrong, every other part of your integration can be perfect and the numbers will be empty.

RevenueCat

1. Project → Integrations → Webhooks → Add. 2. URL: the revenuecat form above. 3. Authorization header value: the token we issue for your application. Paste it exactly as given. Both Bearer <token> and a bare token are accepted, because RevenueCat sends the configured value back verbatim and both spellings exist in the wild. 4. Send all event types. Do not filter: a refund you filtered out is a refund that stays in the numbers.

RevenueCat does not sign the body. The token is the whole credential, so
treat it like a password: it is per application, it is held only as a hash on
our side, and it is rotatable without our involvement. If it leaks, rotate it;
that ends the exposure for that one application and nothing else.

Adapty

1. App Settings → Integrations → Webhook. 2. URL: the adapty form above. 3. Authorization: the token we issue, as the header value. Adapty sends the configured string back verbatim with no scheme prefix, so paste the token and nothing else. 4. Enable every event. Same reason.

Adapty does not sign the body either. Everything said above about the
RevenueCat token applies word for word.

Stripe

1. Developers → Webhooks → Add endpoint. 2. URL: the stripe form above. 3. Events: at minimum the subscription lifecycle, the invoice events and the charge refund events. When in doubt, send all of them. 4. Stripe shows you a signing secret (whsec_...). Send it to us through whatever channel you were given for credentials. Do not put it in a ticket, a chat message or a repository.

Stripe is the only one of the three that signs the bytes:

Stripe-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]

signed_payload = t + "." + rawBody
expected       = HMAC-SHA256(signing_secret, signed_payload)

The accepted timestamp age is 300 seconds in both directions, matching Stripe's own libraries.

What to expect back

200 for a first delivery and 200 for a duplicate. Providers retry on anything else, so a duplicate answered 409 would be retried forever and the provider would eventually disable your endpoint.

401 means the credential did not verify. 404 means the {app} slug is not one we know. Both are worth an alert on your side: a provider that starts getting 401 has had its credential rotated out from under it.

Two rules about currency, if you are also reading your own numbers

1999 and USD, never 19.99 and never a pre-formatted string. No float touches an amount anywhere in this system, in either direction.

redone against a rate frozen at the time of the event, and both the original amount and the converted one are kept. If your own reconciliation disagrees with ours by a fraction of a percent, this is the first place to look.

Before you go live

Send one real test event from the provider's dashboard and confirm it was accepted. The commonest integration defect is not a wrong URL — it is the right URL configured in the sandbox project and never in production, which produces a perfect test and silence on launch day.

10The click URL and the macro reference

Your app does not build this URL. A traffic partner does, in their own panel, and it is here because two things about it reach your code: the Android install referrer, and the parameter names your operator will ask you about.

The two forms

https://go.protect2lab.com/t/c/<campaign>?cid={clickid}&pub={pubid}&sub={subid}
https://go.protect2lab.com/t/c?campaign=<campaign>&cid={clickid}&pub={pubid}

Both are supported permanently. The query form is not legacy tolerance for its own sake: a link already live in a partner's panel cannot be edited without a request to that partner and a wait, and a campaign that breaks quietly in between costs the traffic it was carrying. A partner's existing link keeps working with nothing changed but the hostname.

Resolution order is path first, then the query forms.

Parameters

These are our canonical names. A partner's own parameter names are mapped onto them by your operator, so a partner sending clickid, aff_pubid and sub_zone needs no template change — but these names always work.

ParameterWhat it carries
campaignThe campaign. It may also travel in the path as /t/c/{campaign}.
cidThe traffic partner's own click id. It is kept on the click for reconciliation with that partner; it is NOT the same value as attribution.click_id, which is ours.
ctCreative tag. Paywall rules may match on it, so it is an identifier and never a human label.
lpLanding page key.
pubPublisher id.
s4Free sub field 4.
s5Free sub field 5.
subSub-publisher id. Stored composed as pub/sub, so the same sub under two publishers never collides.

sub is stored composed as pub/sub, so the same sub-publisher id under two different publishers never collides. That composition is why a link with a sub and no pub lands with neither.

An unknown parameter is not an error and is not read. The whole raw query is kept on the click, so a parameter nobody mapped is discoverable later rather than lost.

The Android &referrer= contract

This is the part that reaches your code.

On an Android destination, the redirect appends exactly one parameter to the store URL:

&referrer=a2l_cid%3D<click-uuid>

a2l_cid%3D<uuid>. Double-encoding it produces a referrer string Play hands back as a2l_cid%3D..., which parses to nothing;

appends one, theirs is forwarded untouched and we do not add a second — one click, one row, one referrer;

In your app, read the install referrer once, as early as you can, with the Play Install Referrer library:

val client = InstallReferrerClient.newBuilder(context).build()
client.startConnection(object : InstallReferrerStateListener {
    override fun onInstallReferrerSetupFinished(code: Int) {
        if (code != InstallReferrerClient.InstallReferrerResponse.OK) return
        val raw = client.installReferrer.installReferrer   // the WHOLE string
        postInstall(installReferrer = raw)                 // send it verbatim
        client.endConnection()
    }
    override fun onInstallReferrerServiceDisconnected() {}
})

Send the whole string, verbatim, in install_referrer. Do not parse out a2l_cid and send only that. The rest of the string is the partner's own parameters and they are read on our side; a client that extracts one value throws away data an operator needs for a dispute and gains nothing.

The install referrer is the deterministic channel on Android. When it is present, matching is exact and no probabilistic probe runs. It is the single highest-value thing to get right in an Android integration.

iOS has no equivalent

There is no install referrer on iOS. Matching there is either an explicit click_id your app captured itself (a web flow, a deep link, a landing page that handed it over) or the probabilistic device tuple from section 7a. Which is why rule 1 in section 0 is a hard rule on iOS specifically.

click_id, when you have it

If your app obtained a click id itself — a universal link, a deep link, a landing page that passed it through — send it in click_id on /t/i. That is the exact channel on both platforms and it beats every probe.

Send it once, on the install call. Do not store it and re-send it on later launches: the install is already resolved and the field is ignored, but a client that keeps re-sending a stale click id is a client that will eventually send the wrong one.

11The curl cookbook and the sandbox identity

The sandbox identity

There is one reserved application identity whose paywall answers are chosen rather than waited for. It exists so that you can run every branch your code has to handle without owning a campaign, buying traffic, or waiting for a real click.

app_id   a2l-sandbox
key id   issued to you with the identity
secret   issued to you with the identity -- it is a credential, treat it as one

The secret is an opaque string, and the HMAC key is that string's own UTF-8 bytes. It is not hex and it is not base64 to be decoded: pass it through unchanged. See section 5.

The branch is selected by the app_version field of the signed request:

Send app_versionBranchYou get
0.0.0-sbx-paid-nflpaid/nflshow:true, not dismissible, two products, the primary one with an introductory offer.
0.0.0-sbx-paid-no-trialpaid/no-trialshow:true, not dismissible, one product, has_intro_offer absent from the JSON entirely.
0.0.0-sbx-organicorganicshow:true, dismissible, max_impressions_per_day:2, one product.
0.0.0-sbx-degradeddegradedshow:false, reason:"degraded", degraded:true, no products. Still 200 and still correctly signed.

Four properties of those answers, and they are properties rather than intentions — there is a test in the server repository that asserts each one:

1. Byte-identical. The same request sent ten times produces ten identical decisions. No clock, no random source, no stored state. If you see the bytes move, that is a bug and we want to hear about it. 2. The attribution half is honest. A sandbox install with no click behind it is organic, with is_paid:false and confidence:0 — including under the branch called paid/nfl. The branch names the PAYWALL answer, which is the half that costs money to render wrongly. If you need a paid attribution end to end, you need a real click. 3. An unrecognised app_version is not a branch. A typo answers show:false with reason:"no_rule_match", which is what a real application with no configuration answers. It does not quietly fall back to the organic branch, because a sandbox that did would tell you your typo works. 4. The product ids resolve in no store. a2l.sandbox.* is reserved. Wire one into a real build and you get an empty catalogue and the no_products branch, which is the correct lesson.

The selectors are deliberately unparseable as versions (section 6), so a build that shipped one is immediately visible rather than subtly mis-gated.

The runnable reference integration

The cookbook above is the shortest honest way to make ONE call. This is the other half: a complete, runnable integration with no dependencies, whose tests are the rules on this page.

Download the reference integration — 34 files, 65 KB, sha256 182a1a019015f78b4843a92db82c45cbd790b2ea2be4480188e911668fc4234a.

Unzip it and run npm test. There are no dependencies, so there is nothing to install, and the two cross-implementation fixtures travel inside the archive — the suite runs before the first network call.

filebytes
PLATFORM-NOTES.md4204
README.md6025
app/index.html2842
app/main.js3858
app/paywall.js4125
contract/apply.js3396
contract/attribution.js2503
contract/cache.js2433
contract/canonical.js4217
contract/events.js1881
contract/ladder.js1718
contract/launch.js8516
contract/paywallscreen.js3028
contract/products.js3887
contract/sign.js4784
package.json975
server/client.js3752
server/env.js2494
server/index.js4806
store/fakestore.js4234
test/_clock.js1148
test/_fixtures.js2226
test/antipatterns.test.js10550
test/cache.test.js2390
test/events.test.js2055
test/hmac_vectors.test.js3923
test/ladder.test.js1538
test/launch.test.js10360
test/response_signature.test.js4503
test/secret_stays_on_the_server.test.js4527
testdata/client_contract/response_signature.json18103
testdata/hmac_vectors.json10185
tools/live.js4857
tools/mockserver.js7957

What is in it, and why each piece is there rather than described:

of this document, written as a transcription rather than an interpretation, so a port to Swift or Kotlin is mechanical. Port it against the fixtures.

started before the tunnel. test/launch.test.js asserts their order, not the presence of a comment.

contract: an unverified response applies no paywall and no product, while the attribution half is kept so the install stops retrying.

by input and asserts the output. Break a fix and a test goes red whatever the comments say.

environment. A test walks the import graph and fails if the browser half can reach the signer, because a page that signs client-side ships your credential to everyone who opens it.

you have a key. It really verifies what you signed and really signs what you verify — but it decides nothing, and PLATFORM-NOTES.md is the list of things a browser example cannot teach.

Signing a curl by hand

/t/i, /t/d and /t/e are signed, so there is no single-line curl for them. This script is the shortest honest version. Save it, export your secret, run it.

#!/usr/bin/env bash
# a2l-sign.sh -- sign and send one /t/i call.
# Usage:  A2L_SECRET=<the secret, verbatim> ./a2l-sign.sh 0.0.0-sbx-paid-nfl
set -euo pipefail

: "${A2L_SECRET:?export A2L_SECRET, do not paste it into this file}"
# ★ THE CLICK HOST, and not the one you paste a webhook URL into. Every
# signed endpoint -- /t/i, /t/d, /t/e -- is served here, and only the
# provider webhook of section 9 lives anywhere else. The two hosts are
# different machines with different protection in front of them, and a call
# sent to the wrong one can be answered by a challenge page instead of JSON.
HOST="${A2L_CLICK_HOST:-https://go.protect2lab.com}"
KEY_ID="${A2L_KEY_ID:-sbx-k1}"
APP_ID="${A2L_APP_ID:-a2l-sandbox}"
APP_VERSION="${1:-0.0.0-sbx-organic}"

# The client_nonce is CHOSEN BY YOU and must be the same on every retry of the
# same first launch. 16 bytes of hex.
CLIENT_NONCE="$(openssl rand -hex 16)"

# On the first call the signature subject is the client_nonce, because the
# server has not minted an install token yet.
SUBJECT="$CLIENT_NONCE"

# One line, no trailing newline: the hash must be over the EXACT bytes sent.
BODY=$(printf '%s' "{\"app_id\":\"$APP_ID\",\"app_version\":\"$APP_VERSION\",\"client_nonce\":\"$CLIENT_NONCE\",\"build\":{\"channel\":\"production\"},\"sdk\":\"revenuecat\",\"available_products\":[\"a2l.sandbox.annual.trial\",\"a2l.sandbox.annual.notrial\",\"a2l.sandbox.monthly\"],\"supported_templates\":[\"video_hard\",\"simple_list\"]}")

TS="$(date -u +%s)"
NONCE="$(openssl rand -hex 16)"
BODY_SHA="$(printf '%s' "$BODY" | openssl dgst -sha256 -r | cut -d' ' -f1)"

# method \n path \n canonical_query \n ts \n nonce \n subject \n body_sha256
# There is no query on /t/i, so canonical_query is the empty string -- which is
# an EMPTY LINE in the canonical string, not an absent one.
CANON="$(printf 'POST\n/t/i\n\n%s\n%s\n%s\n%s' "$TS" "$NONCE" "$SUBJECT" "$BODY_SHA")"
# ★ `key:` AND NOT `hexkey:`. The issued secret is an opaque string and the HMAC
# key is its own bytes. `hexkey:` would hex-decode it, which openssl does without
# complaining, and every call would answer 401 with a request that looks perfect.
SIG="$(printf '%s' "$CANON" | openssl dgst -sha256 -mac HMAC -macopt "key:$A2L_SECRET" -r | cut -d' ' -f1)"

curl -sS -D /tmp/a2l-headers.txt -X POST "$HOST/t/i" \
  -H 'Content-Type: application/json' \
  -H "X-A2L-Key: $KEY_ID" \
  -H "X-A2L-Ts: $TS" \
  -H "X-A2L-Nonce: $NONCE" \
  -H "X-A2L-Sig: $SIG" \
  --data-binary "$BODY" | tee /tmp/a2l-body.json

echo
echo "--- response signature ---"
grep -i '^x-a2l-' /tmp/a2l-headers.txt || true
echo "canonical string to verify it against:"
printf 'a2l-response-v1\n%s\n%s\n%s\n' \
  "$SUBJECT" "$NONCE" \
  "$(openssl dgst -sha256 -r < /tmp/a2l-body.json | cut -d' ' -f1)"

Two deliberate details in that script, because both are mistakes people make here:

hash would be over bytes that differ from the bytes sent, and you would get 401 signature_invalid with a request that looks perfect.

appears as an empty line in the canonical string — note the \n\n in CANON. Collapsing it is the other way to get a perfect-looking 401.

All four branches

for v in 0.0.0-sbx-paid-nfl 0.0.0-sbx-paid-no-trial 0.0.0-sbx-organic 0.0.0-sbx-degraded; do
  echo "=== $v"
  ./a2l-sign.sh "$v" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin).get("paywall"),indent=1))'
done

Determinism, in one command

for i in $(seq 1 10); do
  ./a2l-sign.sh 0.0.0-sbx-paid-nfl \
    | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["paywall"],sort_keys=False))'
done | sort -u | wc -l
# -> 1

Ten calls, one distinct paywall block. Each call has its own client_nonce, its own nonce, its own decision_id and its own install_token, and the decision is still identical — which is the property you can build a test on.

The unsigned endpoints

/t/c takes no signature, so it is a plain curl. Use -i and read the Location header; do not follow the redirect.

curl -sS -i "https://go.protect2lab.com/t/c/<campaign>?cid=TEST-CLICK-1&pub=TESTPUB&sub=TESTSUB" \
  | sed -n '1p;/^[Ll]ocation:/p;/^[Cc]ache-[Cc]ontrol:/p'

Expect 302, a Location pointing at the store or a landing page, and Cache-Control: no-store, private. On an Android destination the Location carries exactly one &referrer=a2l_cid%3D... (section 10).

A note on what not to leave behind

The sandbox secret is a credential. Export it; do not commit it, do not paste it into a chat, and do not bake it into a build you ship — including a debug build, which is extractable. If it leaks, tell us and we will rotate it; it is scoped to this one identity and ending it costs nothing.

12Anti-patterns, with the real code that caused each one

This is not a style guide. Every item below is a defect that shipped in the application this system was built for, with the file and line it shipped at. A developer reads a numbered list of real past mistakes; nobody reads a style guide.


(a) Never falsy-check a decision field

// src/helpers/network.js:123
salePackage: data?.package || -1,

0 is a valid tier index. 0 || -1 is -1. So every user the server assigned to tier 0 — the first tier, which is the default and therefore the largest group — was handed -1, fell through every branch, and got whatever the last else happened to do.

It survived review because the line reads as a sensible default. It survived testing because the test account was not in tier 0.

Use a presence test, not a truthiness test, for every member of a decision: show, dismissible, max_impressions_per_day, every index and every count.

const pkg = data?.package ?? -1;      // nullish, not ||
// or, better, because -1 is itself a magic value:
if (typeof data?.package !== "number") { /* no decision -- use the safe default */ }

The same trap in our own wire format: dismissible:false is sent precisely so that a falsy check on it is wrong. max_impressions_per_day may be 0, which means zero and not absent.


(b) SDK initialisation must never write a default product selection

// src/helpers/purchaseHelper.js:30 -- inside initPurchases()
await getProducts();

// ... and getProducts(), at :41-49, writes the shared selection:
setPurchaseProducts(offerings.all.newPremium?.availablePackages);
setOnboardingProduct(offerings.all.onboarding?.availablePackages?.[0]);
setAdProduct(offerings.all.ads?.availablePackages?.[0]);

initPurchases() runs at launch, in parallel with the decision fetch, and it writes the same stored keys the decision writes. Whichever finishes last wins. On a fast network the decision won and the user saw the configured paywall; on a slow one the initialisation won and the user saw the hardcoded default — the configuration silently did nothing, and the arm comparison measured network latency.

Loading the catalogue and choosing from it are two different operations. Initialisation may read the catalogue and must write nothing that the decision also writes. One writer per key.


(c) x?.y !== null is not a presence check

// src/helpers/purchaseHelper.js:41
if (offerings.all.newPremium?.availablePackages !== null) {
  setPurchaseProducts(offerings.all.newPremium?.availablePackages);
}

When newPremium does not exist, offerings.all.newPremium?.availablePackages is undefined. And undefined !== null is true. So the guard passes, and setPurchaseProducts(undefined) erases the catalogue that was already loaded.

The same three lines repeat at :44 and :47 for two more offerings. One mis-keyed offering in a dashboard wiped the whole product list, and the paywall mounted with no buy button and no error.

if (pkgs != null && pkgs.length > 0) { setPurchaseProducts(pkgs); }
// `!= null` is the one loose comparison worth using: it catches both.

Use == null / != null, or an explicit undefined check. Never !== null alone against a value an optional chain produced.


(d) Never ship a paywall without a close path, Restore, Terms and Privacy

src/screens/PremiumVideo.tsx    223 lines -- no close control, no Restore
                                               button, no Terms link, no Privacy link
src/screens/PremiumSecond.tsx   209 lines -- the same four, all four missing

Both files import restore at line 24 and never render a control that calls it.

All four are App Store Review Guideline 3.1.2 requirements, and the first one is also the reason behind the measured behaviour: a paywall with no way out produces uninstalls, not purchases.

Checklist for every paywall screen you ship, including a "temporary" one:

points, and is not a transparent corner;

7);

appear. "Not dismissible" is a product decision about timing, never permission to ship a screen with no exit.


(e) Never hardcode the subscription period or the trial text

// src/screens/PremiumSecondAlt.tsx:202
{'Subscription price is 123123 per month'.localize.replace(
  '123123',
  selectedPackage?.product?.priceString,
)}

The price is substituted from the store, correctly. The period is the string literal per month — and this screen is used for an annual product. Every user on this paywall was told an annual price was a monthly one.

That is a refund, a chargeback, a one-star review, and on repetition a store review finding. It is also invisible in testing, because a tester who knows the product does not read the sentence.

Build the whole sentence from the store's own period and the store's own introductory-offer state, for the product you are actually about to sell:

const p = pkg.product;
const period = formatPeriod(p.subscriptionPeriod);     // from the STORE
const intro  = p.introductoryPrice;                    // from the STORE, this user
const line = intro
  ? t("paywall.trialThenPrice", { days: intro.periodNumberOfUnits, price: p.priceString, period })
  : t("paywall.priceOnly",      { price: p.priceString, period });

And remember that has_intro_offer on our side is the product's capability, never this user's eligibility (section 7). Only the store knows whether this account is eligible.


(f) The permanent orphan — the one that cost the most

Not in the plan's list of five, and it belongs here because it is the most expensive of all of them and it is two lines in two files.

// src/helpers/network.js:119
await setUserToken(data?.userToken);     // unconditional

// src/screens/PreLoad.js:76
if (isNull(vl)) {                        // vl = the stored token
  getLaunch().then(...)                  // the attribution call
}

On an organic response, data.userToken was the user's address. Stored unconditionally, it made isNull(vl) false on the very next launch — so the attribution call was never made again, ever.

Every user who opened the app before their click row had landed was permanently unattributable, and every subscription and every renewal they produced for the rest of their life was booked as organic revenue.

The rules that prevent it are in section 4 and they are worth repeating here:

terminal:true.** Not from a stored token being non-empty, not from a timeout, not from your own reading of kind;

A single "have we called the tracker" boolean is this bug;


(g) The cold-start race

// src/screens/PreLoad.js:57-63
(async () => {
  await Promise.all([initPurchases(), fetchRemoteConfig()]);
  setTimeout(() => { goMain(); }, 10000);     // installed AFTER the await
})();

Three defects in six lines: the navigation timer is installed after the promises resolve, so a hung initialisation never installs it; the network layer those promises use has no timeout at all, so a hung request never resolves; and nothing cancels goMain, so a decision arriving at 10.4 seconds found the user already on the main screen.

Section 4 is the replacement: a real timeout, a real budget, one latch, and no fixed-delay navigation timer anywhere.

13Porting appendix — the exact lines to delete

This appendix is for the existing React Native application only. If you are writing a new app, stop at section 12.

It is a deletion list rather than a refactor plan, because every line below is one whose *presence* is the defect. Each entry names the file and line, what it does, and what replaces it.

Delete

File and lineWhat it isReplace with
src/screens/PreLoad.js:60-62setTimeout(goMain, 10000) installed after await Promise.all(...)the latch and the 2500 ms budget from section 4
src/screens/PreLoad.js:65-67setTimeout(() => setLoading(true), 5000)the 600 ms splash floor
src/screens/PreLoad.js:53-55the 150 ms timer feeding the same navigationnothing. One latch, one navigation
src/screens/PreLoad.js:76if (isNull(vl)) gating the attribution call on a stored tokenif (attributionState === "pending"), with the three states from section 4
src/helpers/network.js:119await setUserToken(data?.userToken), unconditionalstore install_token from the response, and only that
src/helpers/network.js:123a falsy default on data?.package, which turns a valid tier 0 into -1??, or a typeof check — section 12 (a)
src/helpers/purchaseHelper.js:30await getProducts() inside initPurchases()load the catalogue; write no shared selection — section 12 (b)
src/helpers/purchaseHelper.js:41,44,47three !== null guards around setPurchaseProducts / setOnboardingProduct / setAdProduct!= null && length > 0 — section 12 (c)
src/helpers/purchaseHelper.js:180-182setDisplayName(id) then setAttributes(...) then an unconditional Purchases.logIn(id)the guarded binding in section 7b. This one can merge two users' entitlements and is not reversible
src/helpers/purchaseHelper.js:16-18Purchases.configure({ apiKey: "<literal>" }) with the key in sourcethe key from your build configuration. A key in a shipped bundle is extractable
src/screens/PremiumSecondAlt.tsx:202'Subscription price is 123123 per month'the sentence built from the store's period — section 12 (e)

Add

WhereWhat
src/screens/PremiumVideo.tsxclose control, Restore, Terms, Privacy — section 12 (d)
src/screens/PremiumSecond.tsxthe same four
every paywall screenpaywall_impression on mount, paywall_dismiss on close, checkout_started before the purchase call — section 8
the launch paththe no_products event, and the branch that sends it instead of mounting an unbuyable paywall — section 7
the identity paththe identity_bound event, including on refusal — section 7b
Androidthe install referrer read, sent verbatim — section 10
the whole appone signer, one place. Not a helper per screen

Do in this order

The order matters: steps 1 and 2 stop the bleeding and are independent of everything else, so they ship first even if the rest takes a month.

1. purchaseHelper.js:180-182 — the unconditional logIn. It is the only item on this list that does damage which cannot be undone, and the fix is one guard. 2. network.js:119 and PreLoad.js:76 — the permanent orphan. Every day this stays, more users become permanently unattributable, and those users cannot be recovered later. 3. network.js:123 — one character (|| → ??), and it un-breaks the largest tier. 4. Section 4's launch path — the latch, the timeouts, the splash window, the cache, the safe default. This is the real work and it touches PreLoad.js wholesale; do it as a replacement, not as edits. 5. Section 12 (d) on both paywall screens, before your next store submission. 6. Section 8's events. Last, because nothing else depends on them — and first in importance to your operator, who has no numbers until they arrive.

Two things not to do while porting

Do not keep the old tracker call beside the new one "until we are confident". Two clients writing two attribution states for one install produces installs whose state depends on which call finished last, and the disagreement is invisible on both sides.

Do not port the hardcoded paywall as a fallback for show:false. show:false is a decision and it means do not show a paywall. Substituting your own is how a server-side rule that decides not to show a paywall for a segment stops working, with nothing anywhere recording that it did. The only case where you render your own paywall is a response that arrived and failed its signature check — section 3, rule 2.