Troubleshooting
You will walk the path from payment to access in the app through two cabinet logs and one request, and find the place where it broke.
Before you start
Both logs live in the project owner's cabinet, in the "App connection" section.
| What to check | Where it is | Who sees it |
|---|---|---|
| SDK logs: what the app sent | "SDK logs" subtab, "What the app has been sending" section | everyone except a project member |
| Delivery log: what we sent to your server | "For developers" subtab → "Events to your server" → "Log" button in the endpoint row | all project roles |
If you have no cabinet, ask the project owner to invite you to the project. The admin role is needed separately: only it issues access keys and sets up webhook endpoints. Who invites whom and what roles exist: Project and team and Handoff to a developer on the owner track.
Where to start
| Symptom | Where to look | What it means |
|---|---|---|
| the app says "no access", but the money was charged | first "SDK logs" for this device | there are entries: go to the entitlement request; no entries at all: the app never reached us |
| the entitlement answer has an active grant, but the library says "no access" | SDK, step 4 | the library reads only the most recent grant; if it has expired or been revoked, the older active one is not visible |
| iOS: payment went through in the web funnel, the return link opened the app, but there is no access | "SDK logs": step identify.cached_guid instead of a code exchange | the library already stored another identifier and did not exchange the code: SDK, step 3 |
| Android: after payment in the browser window the callback came back empty | SDK, step 5 | the library waited for the entitlement for a minute from the moment the window opened; ask for the entitlement again |
the paywall did not open from the app: unavailable or nil | SDK, step 5 | no configure, or no screen found by ID: not published, or the domain is bound to the funnel and not to the screen |
Android: the paywall or quiz opened and then closed on its own with Unavailable | "SDK logs": step paywall.webview_process_terminated_twice | since version 0.7.2, the page in the embedded window crashed twice in a row and the library closed the display; had the payment gone through, Paid would have come: SDK, step 5 |
| your server did not get the purchase event | the endpoint's delivery log | an attempt row with a response code: your server did not accept it; a decision row: we did not send, the reason is in the row |
any /s2s/v1/* request answers 403 with the code bridge_tier_expired in the body | the "Door closed by plan" section below | not one operation is off but the whole integration: the paid period has ended |
the same 403, but the code bridge_temporarily_unavailable | nowhere: there is nothing to check | this is not about your plan. A failure on our side: we could not check the plan. Your keys and payment are fine; repeat the request later |
| paid content is open to those who did not pay | the testMode field in the entitlement answer | test mode is on: real purchases are not read at all |
test mode was turned on, but the answer has testMode: false | the "Check without waiting for a real payment" section below | the mode is not saved, it does not exist on your connection path, or the project does not know the identifier |
Did the request from the app arrive
The "SDK logs" subtab is the feed of what our library sends: UTC time, level, step name (identify.resolve_failed and similar), message and device. Filters: period, level, step name and identifier.
The second view of the feed, "By device", groups rows by the same buyer identifier (guid) you use to ask for the entitlement; the cabinet labels it "Device". Start with this view when working on a specific person's complaint.
An empty feed is a diagnosis, not a broken log. Entry intake always answers "accepted" and checks nothing: a batch with someone else's or a non-existent project number is dropped silently. So it is one of four: the app has not been launched yet, it holds another project's number, it does not use the library, or the library version is older than the log (on iOS the log exists since 0.6.0, on Android since 0.7.0). The "Main" subtab tells the first apart: it says either "The app has not contacted us yet." or "The connection works." with the date of the last install.
Entries go out in batches, so the last seconds may not have arrived yet: refresh the feed in half a minute. The feed writes the library version next to the platform. Step names and how to see them in the console on your device: SDK.
If you built the integration on your server, without our library, you will not have this feed: work through the two remaining places.
What the entitlement answers
curl "https://api.subster.ai/s2s/v1/entitlement?guid=<GUID>" \
-H "Authorization: Bearer sk_live_…"
Any key will do: the key mode and the Stripe payment environment are different things. A test key reads the entitlement too, and purchases made in the sandbox are visible in the answer.
| What is in the answer | What it means |
|---|---|
a grant with the status active | we have the payment: check your side, how the app reads the answer. Our library looks only at the most recent grant: SDK |
the list of grants (grants) is empty | no payment is tied to this identifier. The entitlement may not have been issued yet: repeat in a few seconds. Still empty: the identifier is wrong. Check in the SDK logs which one the library gave this person, or, if your server created the customer, in your database |
testMode: true | test mode is on, real purchases are not read |
404 | the identifier is unknown or belongs to another project |
Go through the whole list of grants, not the first one: access is open if at least one has the status active. What each field means: Check the entitlement.
Do you deliver access through Adapty or RevenueCat? Then also check the profile link: GET /s2s/v1/identity/link-profile?guid=<GUID> shows which profile we stored. The answer linked: false is the diagnosis "profile not linked", not a request error: we have the payment, but it did not go to the subscription platform because there was no one to give it to. How to link a profile: Adapty and RevenueCat.
Did our event arrive
The "Log" button in the endpoint row opens its deliveries for 30 days. Rows come in two kinds, and this is the main fork of the investigation.
An attempt: we sent. The row has your server's response code and the attempt number. An error here means your server did not accept it: we retry up to eight times, doubling the pause from 30 seconds, within about an hour.
A decision row: we did not send. It has no response code; instead it has a reason and a counter of how many times the decision repeated within an hour. There are six reasons, the list is closed: no others will appear in the row.
| Reason | What it means |
|---|---|
consent_denied | the visitor refused tracking; applies only to analytics events, transactional ones always go out |
not_subscribed | the event type is not enabled for this endpoint, or the property name is not on its list |
bridge_tier_expired | the paid period has ended, see below |
rate_limited | a ceiling of 300 events a minute per address; it applies only to the funnel step stream (funnel.analytics_event) and not to other types. Events above the ceiling are skipped without retries |
duplicate_event | this same event is already queued for delivery |
not_live_traffic | service traffic: our funnel speed measurement; such events are not sent to endpoints, nothing needs to be done |
No row at all means the endpoint is off: rows are written only for enabled addresses. We turn an address off ourselves if within three days there were deliveries to it and none went through. The project owner gets an email, and the owner or a project admin can turn the address back on with a button in the same row. Manual checks do not count toward this.
Door closed by plan
The integration is part of the paid plan. When the organization stops being paid, everything keeps working for seven more days, and only then is the integration turned off as a whole: server requests, event intake and sending events to your server, all at once, not one operation at a time.
The seven days count from the first unpaid day, the first failed charge, not from the subscription closing. The project owner gets the shutdown date by email.
| Where it shows | What comes |
|---|---|
/s2s/v1/* | 403, with the code bridge_tier_expired in the body |
/public/event and /public/identity/resolve-by-email | 404 without a code: a plan refusal there looks like any other |
| delivery log | events stop arriving; the log has the decision row bridge_tier_expired |
Keys are not revoked. Back on a paid plan, the integration works with the same keys and the same secret; there is nothing to reissue.
Other refusal codes close one operation, not the whole integration; they are covered in Money from the app.
The cabinet hides the section before the integration turns off. As soon as the plan becomes free, the "For developers" subtab stops showing keys and webhook endpoints, while requests still work during the grace period.
Check without waiting for a real payment
| What we check | With what |
|---|---|
| the app opens paid screens | test mode, only on the "Our library inside the app" path |
| the whole path: payment, handoff, entitlement, events | Stripe test keys |
| your event handler and signature check | the "Send a test event" button |
Test mode is turned on in "App connection", "Main" tab: in the "What else the connection can do" group, expand the "Check the connection before the first payment" branch, press "Turn on", then "Save settings" at the bottom of the tab. The button in the branch and the "Turn off" link in the orange bar change only unsaved settings: until they are saved, the server keeps the previous mode. The owner or a project admin turns it on.
While the mode is on, the entitlement answers with a made-up active grant for any identifier the project knows: a funnel visitor or a customer created by your server. An unknown identifier gets the usual answer: an empty grants on the public address and 404 on the server one.
The level in the made-up grant is always test, not the one set up in the project: an app that matches the level against its own word will not open anything, so for the duration of the check treat this level as matching too. Real purchases are not read meanwhile, so the mode is not turned on in a live project.
On the "Your own server" and "I am not connecting an app" paths, our server keeps the mode off, so the tab has no "Check the connection before the first payment" branch. The "Show the remaining settings" link under the "Save settings" button opens it, but "Turn on" is unavailable there. Test with Stripe test keys.
You can confirm the mode is on with two requests: create a customer from your server (a test key will do) and ask for its entitlement.
curl -X POST "https://api.subster.ai/s2s/v1/users" \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{"externalId": "test-mode-check"}'
# { "guid": "<GUID>", "user_id": "<GUID>", "paywallUrl": null }
curl "https://api.subster.ai/public/entitlement?guid=<GUID>"
# { "guid": "<GUID>", "testMode": true, "grants": [{ "level": "test", "status": "active", … }] }
Stripe test keys give the only full run: the project owner switches the payment environment to test, and the funnel can be walked end to end with a test card. No money is charged, while the purchase, the handoff and the events happen for real. Test environment prices do not work in the live one: they are created there again.
"Send a test event" is in the endpoint row. We send an event of the type webhook.test, and your server's answer appears in the log: this checks both the handler and the signature. You cannot subscribe to this type, and such checks do not affect turning the address off.
Before going live
Go through the list when testing is done and the funnel is about to go into ads.
- Test mode is off and saved. The entitlement answer for a project identifier comes with
testMode: false. - The payment environment is live. The project owner switches it: Pricing and payments, step 4. After that test payments are not accepted.
- Each live price has an access level. Test environment prices do not work in the live one, and the ones created again have new identifiers. Without a "price → level" pair, the app gets the price identifier instead of its own word, and the screen with the return-to-app button will not publish. The pairs are set in the cabinet: Handoff to a developer, step 5.
- The server has the live key
sk_live_…if you charge or refund money. A test key gets the refusaltest_key_not_allowedon these operations. - The webhook endpoint address is reachable from the internet. We do not call addresses inside your network. Press "Send a test event": the log should show a
2xxanswer from your server. - For Android, the signing certificate fingerprint is entered, not the upload key one. A mixed-up fingerprint breaks the transition to the app without a visible error: Return to the app.
Next
- Check the entitlement: what each answer field means and why an empty list comes for an unknown identifier.
- Webhooks: how events, their signature and retries work.
- Return to the app: setting up the transition if the customer does not reach the app at all.
- MCP server: connect our integration digest to your AI assistant so it works through complaints with our texts, not from memory.