Check the entitlement
You will ask our server the main question of the integration — has this customer paid for access — and read the answer field by field.
The customer paid on a web page, outside the app store, and came back to the app. You decide whether to open paid content, and only our server has reliable payment data. The answer is the list of the customer's grants: active, expired and revoked, each with its own level and term. One parse of this list covers the first purchase, a renewal and a cancellation.
Before you start
- Buyer identifier (
guid) — the value the payment went to; how it reaches the app — “How we recognise the customer”. If the server has only the email, see the section “If the server has only the email” below. - API address —
https://api.subster.ai, also in the cabinet: “App connection” → “For developers”. - Access key — only for the request from your server; where to issue it — “Authentication”.
Which of the two requests is yours
Two addresses return the entitlement. They compute it the same way and differ in input and in how complete the answer is.
| If you have | Request | What you get |
|---|---|---|
| your own server | GET /s2s/v1/entitlement?guid=<GUID> with the key in the header | the list of grants, a link to the subscription management portal and a revenue projection |
| an app without its own server | GET /public/entitlement?guid=<GUID>, no key needed | the same list of grants |
Start with your own server if you have one. A decision made on the device can be forged along with the device, and the public address answers anyone who knows the identifier. What the app knows about the subscription is fine for the interface; gate paid content on the answer your server received.
You have both the library and your own server. Combine them: the library recognises the customer and gives the app the identifier, the app passes it to your server, and the server requests the entitlement with the key. Which path to choose in the cabinet for this combination — “Overview”, section “Which path is yours”.
Request and response
curl "https://api.subster.ai/s2s/v1/entitlement?guid=<GUID>" \
-H "Authorization: Bearer sk_live_…"
| Parameter | Required | What it is |
|---|---|---|
guid | yes | buyer identifier, 8 to 64 characters |
user | no | the same value under a second name, kept for compatibility with old integrations — no need to pass it. Accepted only by the key request. Both names with different values give the refusal guid_user_mismatch, neither — guid_required |
The address knows no other parameters: an extra one, for example a cache-buster, gets a 400 refusal. The exception is the deprecated externalId on the key request: it is accepted but does not affect the answer.
{
"guid": "abc123def456",
"user_id": "abc123def456",
"testMode": false,
"grants": [
{
"level": "premium",
"status": "active",
"expires_at": "2026-10-21T00:00:00.000Z",
"price_id": "price_1Tc…",
"will_renew": true,
"livemode": true,
"amount": 1999,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"current_period_start": "2026-09-21T00:00:00.000Z",
"current_period_end": "2026-10-21T00:00:00.000Z",
"is_trial": false,
"trial_ends_at": null,
"expiryPending": false
}
],
"manage_link": "https://…"
}
The response is not wrapped: the grants list is at the top level. Some of our other addresses wrap the response in { success, data }, so check the shape against the example for the request you call.
How to decide whether to let someone into paid content
Access is open if at least one grant has the status active:
const paid = data.grants.some(g => g.status === "active");
The status is inside each grant; the top level of the answer has none. Code that reads data.status gets an empty value for every customer, including one who paid. It does not look like an error: the app silently opens paid content to nobody.
| Status | What it is | What to do |
|---|---|---|
active | paid and in effect | let them in |
active with will_renew: false | in effect, but will not renew | let them in until the end date and show it to the customer |
expired | the term has ended | do not let them in, offer to renew |
revoked | access revoked | do not let them in |
There are no other statuses. A trial does not come as a separate status — it is marked by a flag inside an active grant. Access is revoked for a refund or a lost payment dispute, a failed async payment, an expired grace period after a failed renewal, a subscription cancellation and disconnecting the payment system in the project. The answer does not name the reason; for a subscription revocation it comes in the entitlement.revoked event (“Webhooks”).
If the organization has several projects, the list also includes this customer's grants from sibling projects. When projects sell different apps, let people in by the grant's level, not by status alone. A customer from another organization is a different case: there will be no grants for them, and the key request answers 404.
Response fields
Top level:
| Field | Type | When present | What to do |
|---|---|---|---|
guid | string | always | the identifier you passed |
user_id | string | only with a key | the same value as guid |
testMode | boolean | always | true — test mode is on: the list has one made-up grant, see the section below. Checking this field is enough |
grants | array | always, can be empty | grants from newest to oldest, all statuses |
manage_link | string or null | only with a key | a link to our subscription management portal screen, lives 15 minutes. null — the portal is not ready in the project. Details — “Money from the app” |
Grant:
| Field | Type | When filled | What to do |
|---|---|---|---|
level | string | always | open what matches the level. For a price without a set level this holds its identifier — level equals price_id |
status | string | always | active, expired or revoked; let in only on active |
expires_at | ISO 8601 date in UTC or null | null — no term: a one-time purchase, or the subscription term is not computed yet | show the customer “access until …”; read null together with expiryPending |
price_id | string | always | the price access was granted for; match the grant with events and your catalogue by it |
will_renew | boolean | always | false — no renewal. A one-time purchase gets true, although there is nothing to renew |
livemode | boolean or null | null for grants created before the field appeared | false — the payment went through the payment system's test environment, no real money. Connection test mode is something else, marked by testMode |
amount | integer or null | null if this price is not among the project prices our server knows | amount in the currency's minor units: 1999 in usd is 19.99, not 1999 |
currency | string or null | as for amount | lowercase currency code: usd, eur |
interval | string or null | as for amount | day, week, month, year; for a one-time purchase one_time. Tell a subscription from a one-time purchase by this field, not by will_renew |
interval_count | integer or null | as for amount | period multiplier: 3 for an “every three months” price |
current_period_start | date or null | null for a one-time purchase, for a grant without a term, and wherever amount is null | start of the current period |
current_period_end | date or null | always equals expires_at | a second name for the same term, for code that expects a “period start and end” pair; take either |
is_trial | boolean | always | true — a trial is running right now |
trial_ends_at | date or null | only while is_trial: true | when the trial ends |
expiryPending | boolean | always | true — access is already there, but the period end has not arrived yet; this is not lifetime access |
testMode | boolean | only on the made-up test mode grant, together with the top-level testMode: true | no separate check needed |
projected_revenue_32d, _62d, _184d, _367d | integer or null | only with a key and only for subscriptions | average accumulated revenue per customer by day 32, 62, 184 and 367 from the whole project's statistics, in the currency's minor units; the same for all subscriptions. null — not enough data yet. Unrelated to the customer's access |
projection_status | string | same | ok — all four terms computed, insufficient_data — not all |
Check the level against what is configured. “Price → level” pairs are set in the cabinet: “Project settings” → “App connection” → “Main” tab → the “What to open in the app after the payment” branch. A price without a pair comes with its own identifier instead of a level, and an app that compares the level with an expected word opens nothing. In code it looks like this: the grant's level equals its price_id. Pairs are unlocked by the “I have several different levels” checkbox — tick it even with one level: the “Name of the paid access inside the app” field above it is locked.
The subscription term does not appear instantly. The entitlement is granted when the customer returns from the payment page, and the period end follows. In those seconds an active grant comes without a term and with expiryPending: true — the customer already has access. A one-time purchase has no term by design, and its flag is false: that is how the two cases differ.
Why an empty list also comes for an unknown identifier
The public address answers an unknown identifier the same way as a known customer without purchases: with success and an empty list. Otherwise identifiers could be enumerated to find other people's purchases. The key request answers “not found” both for an unknown identifier and for a customer from another organization — these cases are indistinguishable too.
An empty list by itself does not mean the integration is broken. What to check when a customer paid but there are no grants — “Troubleshooting”.
If the server has only the email
Sometimes your server knows the customer's email but did not save the identifier. Then first request the identifier by email with the same key:
curl -X POST "https://api.subster.ai/s2s/v1/identity/resolve-by-email" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'
{ "guid": "abc123def456", "user_id": "abc123def456" }
We search only verified emails — the one the customer gave at payment. It is verified by following our link with a code, most often the return to the app after payment.
Until the customer has returned to the app by a link at least once, expect “not found” — the same refusal as for an unknown email. Recognition on install from the app store or by the device fingerprint does not verify the email.
The same lookup exists on the public address /public/identity/resolve-by-email with secret signing — needed only if there is no key on the server (“Authentication”).
While test mode is on
Connection test mode lets you go through the whole path before the first real payment. Turn it on in the cabinet:
- “App connection” → “Main” tab → the “Check the connection before the first payment” branch → “Turn on”.
- “Save settings” at the bottom of the tab — without it the mode does not turn on.
The mode works on the “Our library inside the app” path. The “Your own server” and “I am not connecting an app” paths do not have it: the cabinet does not show the branch, and saving the settings turns the mode off. About paths — “Overview”.
While the mode is on, the answer comes with testMode: true and one made-up grant: level and price identifier test, status active, no term and no price fields.
Only an identifier the project already knows gets this answer: the customer went through the funnel or your server created them with POST /s2s/v1/users. A made-up identifier gets the normal answer — how to check is in “Troubleshooting”, section “Check without waiting for a real payment”.
Real purchases are not read in such an answer at all. Turn the mode off before going live — otherwise paid content is open to everyone the project knows.
When to ask and whether you can store the answer
Ask for the first time when the app opens after returning from payment — by then the entitlement is usually granted. If the list is empty, repeat the request in a few seconds: occasionally the payment is confirmed not on return but by a notification from the payment system right after.
We have no cache: grants are read fresh on every request. You can store the answer on your side and refresh it from the events about granting, renewal and revocation sent to your server (“Webhooks”). There is no need to poll us in a loop.
Do not store manage_link: take it from a fresh answer right before showing the button. How to answer the app from stored data while we are unavailable — in “Recipes”.
Refusals and limits
| Response | When it comes | What to do |
|---|---|---|
400 | the identifier is missing, shorter than 8 or longer than 64 characters, an extra parameter in the address; for the key request this also covers guid_required and guid_user_mismatch | fix the request: the same request will get the same answer |
404 | only for the key request: the identifier is unknown or belongs to another organization | treat the customer as unpaid |
429 | rate exceeded | retry later |
401, 403 | only for the key request: refusal because of the key | see “Authentication” |
The public address refuses with the body { "statusCode", "message" }, the key request with { "error": { "type", "code", "message" } }.
Rate limit: 60 requests a minute per IP address for the public address, 120 requests a minute per key for the key request. How they are counted — “Authentication”, section “How many requests you can make”.
Next
- Money from the app — subscription cancellation, refunds and the portal the management link leads to.
- Webhooks — learn about renewals and cancellations without polling the server.
- Troubleshooting — the customer paid, but the app shows no access.
- Authentication — the access key, its scopes and request signing.
- Buyer properties — quiz answers, so the app does not ask them a second time.
- Recipes — access check from your server and from the app in full, as ready-made code.