S Subster

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

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 haveRequestWhat you get
your own serverGET /s2s/v1/entitlement?guid=<GUID> with the key in the headerthe list of grants, a link to the subscription management portal and a revenue projection
an app without its own serverGET /public/entitlement?guid=<GUID>, no key neededthe 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_…"
ParameterRequiredWhat it is
guidyesbuyer identifier, 8 to 64 characters
usernothe 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.

StatusWhat it isWhat to do
activepaid and in effectlet them in
active with will_renew: falsein effect, but will not renewlet them in until the end date and show it to the customer
expiredthe term has endeddo not let them in, offer to renew
revokedaccess revokeddo 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:

FieldTypeWhen presentWhat to do
guidstringalwaysthe identifier you passed
user_idstringonly with a keythe same value as guid
testModebooleanalwaystrue — test mode is on: the list has one made-up grant, see the section below. Checking this field is enough
grantsarrayalways, can be emptygrants from newest to oldest, all statuses
manage_linkstring or nullonly with a keya 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:

FieldTypeWhen filledWhat to do
levelstringalwaysopen what matches the level. For a price without a set level this holds its identifier — level equals price_id
statusstringalwaysactive, expired or revoked; let in only on active
expires_atISO 8601 date in UTC or nullnull — no term: a one-time purchase, or the subscription term is not computed yetshow the customer “access until …”; read null together with expiryPending
price_idstringalwaysthe price access was granted for; match the grant with events and your catalogue by it
will_renewbooleanalwaysfalse — no renewal. A one-time purchase gets true, although there is nothing to renew
livemodeboolean or nullnull for grants created before the field appearedfalse — the payment went through the payment system's test environment, no real money. Connection test mode is something else, marked by testMode
amountinteger or nullnull if this price is not among the project prices our server knowsamount in the currency's minor units: 1999 in usd is 19.99, not 1999
currencystring or nullas for amountlowercase currency code: usd, eur
intervalstring or nullas for amountday, 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_countinteger or nullas for amountperiod multiplier: 3 for an “every three months” price
current_period_startdate or nullnull for a one-time purchase, for a grant without a term, and wherever amount is nullstart of the current period
current_period_enddate or nullalways equals expires_ata second name for the same term, for code that expects a “period start and end” pair; take either
is_trialbooleanalwaystrue — a trial is running right now
trial_ends_atdate or nullonly while is_trial: truewhen the trial ends
expiryPendingbooleanalwaystrue — access is already there, but the period end has not arrived yet; this is not lifetime access
testModebooleanonly on the made-up test mode grant, together with the top-level testMode: trueno separate check needed
projected_revenue_32d, _62d, _184d, _367dinteger or nullonly with a key and only for subscriptionsaverage 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_statusstringsameok — 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:

  1. “App connection” → “Main” tab → the “Check the connection before the first payment” branch → “Turn on”.
  2. “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

ResponseWhen it comesWhat to do
400the 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_mismatchfix the request: the same request will get the same answer
404only for the key request: the identifier is unknown or belongs to another organizationtreat the customer as unpaid
429rate exceededretry later
401, 403only for the key request: refusal because of the keysee “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