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.
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.
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).
| Method | Path | Signed | What it does |
|---|---|---|---|
GET | /t/c/{campaign} | no | Records 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/c | no | The 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/i | yes | One signed call on first launch. Answers the attribution result, the opaque install token, and the paywall decision for the launch placement. |
GET | /t/d | yes | Later 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/e | yes | One event or a batch of up to 50, each idempotent on the client's own client_event_id. |
POST | /t/wh/{provider}/{app} | no | Where 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.
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 bodyContent 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.
| Member | Type | Always present | Meaning |
|---|---|---|---|
app_id | string | yes | Your application id, as issued to you. |
app_version | string | yes | Your build's version string. Paywall rules may be bounded by it, so send the real one. |
client_nonce | string | yes | 128 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. |
build | object | yes | The 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. |
fingerprint | object | no | The 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_referrer | string | no | The raw Play install-referrer string, exactly as InstallReferrerClient handed it over. |
click_id | string | no | The exact channel for web and for any client that captured the referrer itself. |
subscriber_ref | string | no | The app's own user identifier. |
available_products | array of string | no | The product ids the client ACTUALLY resolved from the store catalogue -- not the ones it hopes exist. |
supported_templates | array of string | no | The paywall templates this build can render. |
sdk | string | no | The purchase SDK this build speaks: "revenuecat", "adapty" or "stripe". |
build| Member | Type | Always present | Meaning |
|---|---|---|---|
channel | string | yes | production, 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| Member | Type | Always present | Meaning |
|---|---|---|---|
platform | string | yes | ios, android or web. Lowercase. Section 7a. |
os_major | string | yes | The LEADING component of the OS version only: 17.5.1 becomes 17. Section 7a. |
device_family | string | yes | A closed enum: iphone, ipad, android_phone, android_tablet, desktop, other. A raw model string never goes here. Section 7a. |
lang | string | yes | The 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 bodyContent 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 nosigmember and nokey_idmember inside this body.
A signature inside a body cannot sign that body, and any proxy that
re-encodes the JSON would break it.
| Member | Type | Always present | Meaning |
|---|---|---|---|
v | integer | yes | Envelope version. 1 today. Treat an unknown value as a reason to use your cached decision, not as an error. |
decision_id | string | yes | Opaque 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_at | string (RFC 3339) | yes | When 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. |
degraded | boolean | yes | Degraded means a dependency was unavailable and the decision was made on less than the full picture. |
attribution | object | yes | How this install was resolved. See below. |
identity | object | yes | Who your purchase SDK should be. Always present; its members are optional. See below and section 7b. |
install_token | string | yes | Opaque. 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. |
paywall | object | no | The launch decision. |
split | object | no | Present ONLY when the winning rule carried one. |
attribution| Member | Type | Always present | Meaning |
|---|---|---|---|
kind | string | yes | What this install was resolved to. Treat it as an opaque label and branch on is_paid and terminal instead; the set of kinds grows. |
terminal | boolean | yes | Terminal says whether the client may stop asking. |
confidence | integer | yes | 0-100. Informational: it is not a threshold you apply. |
is_paid | boolean | yes | Derived from Kind, NOT from a subscription state. |
click_id | string | no | The click this install was matched to. Absent on an organic install. |
campaign_id | string | no | Absent on an organic install. |
publisher_id | string | no | Absent on an organic install. |
sub_publisher_id | string | no | Composed as publisher/sub. Absent on an organic install. |
creative_tag | string | no | The creative identifier from the click. Absent on an organic install. |
prelander_id | string | no | The 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| Member | Type | Always present | Meaning |
|---|---|---|---|
sdk_user_id | string | no | The 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_attributes | object | no | Attributes 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| Member | Type | Always present | Meaning |
|---|---|---|---|
show | boolean | yes | The only member you branch on first. true means offer a paywall, false means go to your main screen. |
reason | string | no | Present 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. |
placement | string | no | Which placement this decision answers. Absent when show is false. |
variant | string | no | The technical identifier of the screen configuration. Report it in your own analytics; do not display it. |
template | string | no | The screen your build renders. Already verified against your supported_templates[], so you will never receive one you did not declare. Section 6. |
assets | object | no | Copied 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. |
dismissible | boolean | no | Sent 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_day | integer | no | A 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. |
products | array of object | no | Required 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[]| Member | Type | Always present | Meaning |
|---|---|---|---|
role | string | yes | primary, secondary or tertiary. Offer them in this order. A primary that does not resolve means the paywall is not offerable (section 7). |
sdk | string | yes | Which purchase provider this entry addresses: revenuecat, adapty or stripe. You only ever receive entries for the provider you declared. |
id | string | yes | The real store product id. Never an index into an offering -- use it to VERIFY whatever your provider's offering or placement resolved to. |
offering | string | no | RevenueCat Offering key. Absent for the other providers. |
package | string | no | RevenueCat Package identifier, e.g. $rc_annual. Absent for the other providers. |
adapty_placement | string | no | Adapty Placement id. Absent for the other providers. |
adapty_variation | string | no | Adapty paywall variation. Absent for the other providers. |
stripe_price_id | string | no | Stripe Price id. Absent for the other providers. |
has_intro_offer | boolean | no | The PRODUCT's capability, not THIS user's eligibility. |
intro_kind | string | no | What the introductory offer is, e.g. free_trial. Present only when has_intro_offer is. |
intro_period_iso | string | no | ISO 8601 duration of the introductory offer: P3D, P1W, P1M. Present only when has_intro_offer is. |
base_period_iso | string | yes | ISO 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.
splitPresent 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.
| Member | Type | Always present | Meaning |
|---|---|---|---|
arm | string | yes | Which 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_used | string | yes | The salt that bucketed this install. Informational, for reproducing a bucket decision in a support request. |
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.
decision_id is dcn_ plus a UUID; install_token is itk_ plus 32base64url characters. Both are opaque. Do not parse either one, and do not assume those lengths are stable.
expires_at is RFC 3339 UTC. Compare it against a monotonic clock, neverthe device wall clock: a user who moves their clock forward would otherwise discard a perfectly good decision on every launch.
attribution.kind is organic and is_paid is false, **under a branchcalled 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.
identity is an empty object rather than absent, because the block itself isalways present and only its members are optional.
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.
GET /t/c/{campaign}| Status | Meaning |
|---|---|
302 | Redirect to the store or to the configured landing page, with Cache-Control: no-store, private. |
404 | No campaign with that identifier. |
GET /t/c| Status | Meaning |
|---|---|
302 | Redirect, exactly as the path form. |
404 | No campaign with that identifier. |
POST /t/i| Status | Meaning |
|---|---|
200 | The attribution result, the install token and the launch decision. Signed in X-A2L-Sig over the exact response bytes. |
401 | Signature missing, malformed or wrong. |
429 | Too many requests. |
GET /t/d| Status | Meaning |
|---|---|
200 | A signed decision for the placement you asked for. Same paywall and split shape as /t/i. |
401 | The signature is missing, malformed or wrong. |
POST /t/e| Status | Meaning |
|---|---|
200 | THROTTLED, and nothing was stored. |
202 | Stored, or already stored. |
401 | The signature is missing, malformed or wrong. |
422 | The 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. |
503 | The event could not be stored -- nothing is wired, or the write failed. |
POST /t/wh/{provider}/{app}| Status | Meaning |
|---|---|
200 | Accepted. |
401 | Provider signature or app webhook token did not verify. |
404 | No application with that {app} slug. |
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.
code | What it means, and what to do |
|---|---|
bad_request | Malformed request -- unparseable JSON, or a parameter that cannot be read. |
validation_failed | The request parsed but did not validate. See details. |
unauthorized | No credentials, bad credentials, or a session that has expired. |
signature_invalid | The HMAC on a signed client request did not verify. The fix is a rotated key, not a fresh login. |
clock_skew | Signature timestamp outside the 300s window. See server_time. |
not_found | No such resource, or one the caller may not be told exists. |
payload_too_large | The request body is over the limit for this endpoint. |
unsupported_media_type | The content type is not one this endpoint accepts. |
rate_limited | Too many requests. See Retry-After. |
service_unavailable | A dependency is down or the server is shedding load. Retry later. |
internal_error | Unhandled 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.
Section 0 states them. This section is what each one costs and how to satisfy it.
/t/i before the tunnelCall /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:
/t/i goes before the tunnel call, notbeside it. Not Promise.all, not a task group: before.
/t/i retry ladder in section 4 keeps running after the tunnel is up, andthose 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.
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.
| Verified | Did not verify | |
|---|---|---|
paywall.show, template, variant, assets | obey | ignore. Treat as show:false and render your own built-in paywall if you have one |
paywall.products[] | obey | ignore. Offer your own catalogue, never the response's |
attribution.terminal:true | persist | persist anyway |
install_token | store | store |
| 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.
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.
| Value | |
|---|---|
/t/i request timeout | 1200 ms |
| retries on first launch | one, with 400 ms jitter |
| total budget for the whole launch decision | 2500 ms |
/t/d request timeout | 1200 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.
decided latchOne 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.
| Value | |
|---|---|
| floor | 600 ms |
| ceiling | 2500 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.
pending attributionattribution.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.
decided_paid arrives lateOpen 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.
Keep the last known good decision in local storage with its expires_at.
all, and refreshes in the background through /t/d;
expires_at is measured against a monotonic clock. The default lifetime is6 hours, which is also the worst case for an operator's "stop this paywall" reaching your installed base;
expires_at passed, a newer verified decision for the sameinstall, 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.
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.
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.
| Header | Required | Meaning |
|---|---|---|
X-A2L-Key | yes | Key id from app_client_keys. The client carries two -- current and next. |
X-A2L-Ts | yes | Unix seconds. Accepted window is 300s; outside it the answer is code: clock_skew. |
X-A2L-Nonce | yes | At least 128 bits from the platform CSPRNG, hex or base64url. A repeat replays the previous response rather than failing. |
X-A2L-Sig | yes | Lowercase hex HMAC-SHA256 over the canonical string. See A2LSignature. |
X-A2L-Install | yes | The 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.
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, frozenEvery 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:
+ is not a space. It decodes to a literal + and re-encodes to %2B.Most form-encoding helpers get this wrong for this purpose.
%zz re-encodes to %25zz.key=. ?a and ?a= produce the samecanonical string.
path is the RAW request-target path. %2F is not decoded and hexcase 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.
subjectNever 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.
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.
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.
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.
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.
X-A2L-Nonce is at least 128 bits from the platform CSPRNG, hex orbase64url. 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.
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.
paywall.templatetemplate 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:
supported_templates[] on every /t/i — the templates **this buildcan actually render right now**;
template that is not in that array. A configured variantwhose 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.assetsAn 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_versionA 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 build | What happens |
|---|---|
| new enough for rule 1 | rule 1 wins |
| too old for rule 1, matches rule 2 | rule 2 wins — you still get a paywall |
| too old for every matching rule | the placement's default is used |
| no default configured either | show: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.
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.
/t/i only ever asks for launch. The other four are reached through /t/d.
placement | When you ask for it |
|---|---|
launch | First launch, from /t/i. You never ask for this one on /t/d. |
onboarding_end | The last onboarding screen. |
home_cta | A user tapped an upgrade control. |
feature_gate | A user reached something the subscription unlocks. |
late_attribution | Attribution 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. |
**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.
products[] onto each providerFor every entry, in role order (primary first):
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.
entry.adapty_placement -> the Placement id
entry.adapty_variation -> the paywall variation, when present
entry.id -> the vendor product id -- USE THIS TO VERIFY
entry.stripe_price_id -> the Price id
entry.id -> the product id
primary entry that does not resolve: the paywall is not offerable. Donot mount it. Go to your main screen and send one no_products event carrying the decision_id.
secondary or tertiary entry that does not resolve: drop that oneoption 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.
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:
has_intro_offer may be absent entirely from an entry. Absent means nointroductory offer. Check with a presence test, not with !== null (see section 12, item c);
intro_kind and intro_period_iso are present only when has_intro_offeris. 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.
sdk memberYou 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.
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.
| Field | Rule | Examples |
|---|---|---|
platform | A closed set: ios, android, web. Lowercase. | ios |
os_major | The leading component only. Separators are ., _ and -. | 17.5.1 → 17, 14.0.0 → 14, 15_7 → 15 |
device_family | A closed enum: iphone, ipad, android_phone, android_tablet, desktop, other. | iPhone15,3 → iphone, SM-G991B → android_phone |
lang | The 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.
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:
navigator.language and iOS Locale emit the BCP 47 hyphen: tr-TR;Resources.getSystem().configuration.locales[0] is ajava.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.
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.
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:
| Expectation | What it asserts |
|---|---|
declared_eq_native | the landing page's tuple equals the device library's tuple, so a declared click matches its install |
ua_parsed_eq_native | the 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_parsed | the 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.
This section is three rules long and all three were learned from a shipped defect.
logIn(click_id) once, and before any purchase attemptBind 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.
logIn on a device that already has a live subscription under another identityBefore 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".
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.
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.
kind | When your app sends it |
|---|---|
paywall_impression | The paywall is on screen and its buy button can complete a purchase. Not when you decide to show it. |
paywall_dismiss | The user closed a dismissible paywall without starting checkout. |
checkout_started | You called the store's purchase method. Carry product_id. |
checkout_abandoned | The store sheet closed with no purchase and no error you can attribute. |
purchase_client_ack | The store told your app the purchase succeeded. Informational only -- see section 8. |
no_products | The decision said show:true and none of its products[] resolved in the store catalogue. Send it and go to your main screen. |
identity_bound | You 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 revenueSay 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 hereThere 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.
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.
placement loses to the placement of the decision it names. The server'srecord of its own decision keeps the whole funnel of that decision in one reporting cell. The answer reports what was actually stored.
occurred_at is clamped, not refused, to roughly three days back and twominutes 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.
client_event_id is 202 with duplicate:true, never409. You may forget the event either way.
| Status | Shape | What 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 |
200 | throttled:true, accepted falsy | DROP the events. Do not retry |
422 | Error | DROP the event. The body will never parse |
503 | Error | KEEP the event and retry |
401 | Error | signature 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Parameter | What it carries |
|---|---|
campaign | The campaign. It may also travel in the path as /t/c/{campaign}. |
cid | The 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. |
ct | Creative tag. Paywall rules may match on it, so it is an identifier and never a human label. |
lp | Landing page key. |
pub | Publisher id. |
s4 | Free sub field 4. |
s5 | Free sub field 5. |
sub | Sub-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.
&referrer= contractThis 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=<uuid> percent-encoded givesa2l_cid%3D<uuid>. Double-encoding it produces a referrer string Play hands back as a2l_cid%3D..., which parses to nothing;
referrer parameter, ever. If the partner's own template alreadyappends one, theirs is forwarded untouched and we do not add a second — one click, one row, one referrer;
utm_* parameters ride along in the same string for free.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.
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 itIf 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.
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_version | Branch | You get |
|---|---|---|
0.0.0-sbx-paid-nfl | paid/nfl | show:true, not dismissible, two products, the primary one with an introductory offer. |
0.0.0-sbx-paid-no-trial | paid/no-trial | show:true, not dismissible, one product, has_intro_offer absent from the JSON entirely. |
0.0.0-sbx-organic | organic | show:true, dismissible, max_impressions_per_day:2, one product. |
0.0.0-sbx-degraded | degraded | show: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 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.
| file | bytes |
|---|---|
PLATFORM-NOTES.md | 4204 |
README.md | 6025 |
app/index.html | 2842 |
app/main.js | 3858 |
app/paywall.js | 4125 |
contract/apply.js | 3396 |
contract/attribution.js | 2503 |
contract/cache.js | 2433 |
contract/canonical.js | 4217 |
contract/events.js | 1881 |
contract/ladder.js | 1718 |
contract/launch.js | 8516 |
contract/paywallscreen.js | 3028 |
contract/products.js | 3887 |
contract/sign.js | 4784 |
package.json | 975 |
server/client.js | 3752 |
server/env.js | 2494 |
server/index.js | 4806 |
store/fakestore.js | 4234 |
test/_clock.js | 1148 |
test/_fixtures.js | 2226 |
test/antipatterns.test.js | 10550 |
test/cache.test.js | 2390 |
test/events.test.js | 2055 |
test/hmac_vectors.test.js | 3923 |
test/ladder.test.js | 1538 |
test/launch.test.js | 10360 |
test/response_signature.test.js | 4503 |
test/secret_stays_on_the_server.test.js | 4527 |
testdata/client_contract/response_signature.json | 18103 |
testdata/hmac_vectors.json | 10185 |
tools/live.js | 4857 |
tools/mockserver.js | 7957 |
What is in it, and why each piece is there rather than described:
contract/ is pure — no network, no DOM, no globals. One function per clauseof this document, written as a transcription rather than an interpretation, so a port to Swift or Kotlin is mechanical. Port it against the fixtures.
contract/launch.js holds the two lines rule 1 is about: the install call isstarted before the tunnel. test/launch.test.js asserts their order, not the presence of a comment.
contract/apply.js holds rule 2, and is driven by the shared response-signaturecontract: an unverified response applies no paywall and no product, while the attribution half is kept so the install stops retrying.
test/antipatterns.test.js drives each of the five anti-patterns in section 12by input and asserts the output. Break a fix and a test goes red whatever the comments say.
server/ holds the secret and signs; app/ is the browser and reads noenvironment. 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.
tools/mockserver.js is a disposable double, so npm run live works beforeyou 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.
/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:
-macopt "key:…" and never hexkey:. See the comment above it.printf '%s' everywhere, never echo. echo appends a newline, the bodyhash would be over bytes that differ from the bytes sent, and you would get 401 signature_invalid with a request that looks perfect.
canonical_query for a query-less request is the empty string, whichappears 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.
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
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.
/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).
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.
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.
// 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.
// 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.
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.
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);
dismissible is false, the close control may be delayed but mustappear. "Not dismissible" is a product decision about timing, never permission to ship a screen with no exit.
// 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.
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;
pending, decided_paid, decided_organic.A single "have we called the tracker" boolean is this bug;
pending, retry on the ladder, with the same client_nonce.// 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.
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.
| File and line | What it is | Replace with |
|---|---|---|
src/screens/PreLoad.js:60-62 | setTimeout(goMain, 10000) installed after await Promise.all(...) | the latch and the 2500 ms budget from section 4 |
src/screens/PreLoad.js:65-67 | setTimeout(() => setLoading(true), 5000) | the 600 ms splash floor |
src/screens/PreLoad.js:53-55 | the 150 ms timer feeding the same navigation | nothing. One latch, one navigation |
src/screens/PreLoad.js:76 | if (isNull(vl)) gating the attribution call on a stored token | if (attributionState === "pending"), with the three states from section 4 |
src/helpers/network.js:119 | await setUserToken(data?.userToken), unconditional | store install_token from the response, and only that |
src/helpers/network.js:123 | a falsy default on data?.package, which turns a valid tier 0 into -1 | ??, or a typeof check — section 12 (a) |
src/helpers/purchaseHelper.js:30 | await getProducts() inside initPurchases() | load the catalogue; write no shared selection — section 12 (b) |
src/helpers/purchaseHelper.js:41,44,47 | three !== null guards around setPurchaseProducts / setOnboardingProduct / setAdProduct | != null && length > 0 — section 12 (c) |
src/helpers/purchaseHelper.js:180-182 | setDisplayName(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-18 | Purchases.configure({ apiKey: "<literal>" }) with the key in source | the 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) |
| Where | What |
|---|---|
src/screens/PremiumVideo.tsx | close control, Restore, Terms, Privacy — section 12 (d) |
src/screens/PremiumSecond.tsx | the same four |
| every paywall screen | paywall_impression on mount, paywall_dismiss on close, checkout_started before the purchase call — section 8 |
| the launch path | the no_products event, and the branch that sends it instead of mounting an unbuyable paywall — section 7 |
| the identity path | the identity_bound event, including on refusal — section 7b |
| Android | the install referrer read, sent verbatim — section 10 |
| the whole app | one signer, one place. Not a helper per screen |
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.
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.