S Subster

Authentication

You will issue an access key and sign a server request with it — ten minutes if the cabinet is open, longer if you must ask the project owner for the key.

Before you start

Keys are in the cabinet: Project settings → App connection → For developers, “Access keys” section. The “API address” field there holds the base of all addresses below: https://api.subster.ai.

Step 1. Issue an access key

Click “Create a key”, name it and choose the mode — live (sk_live_…) or test (sk_test_…). The value is shown once: lost it — issue a new one.

A test key does everything a live one does, except two things. It cannot charge or refund; such a request gets test_key_not_allowed. An event sent to us with it is a test one and skips ad networks. It has no separate test data — it works with the project's real customers, and cancelling a subscription with it is real.

This is a Subster key, not a Stripe key

The prefixes match, and our signing secret also starts with whsec_, like Stripe's. The owner enters Stripe keys in another cabinet section, and they do not work for our requests: take the key where stated above — the Stripe dashboard does not have it.

Step 2. Sign a request with the key

curl "https://api.subster.ai/s2s/v1/entitlement?guid=<GUID>" \
  -H "Authorization: Bearer sk_live_…"

<GUID> is the buyer identifier: the app gets it on identification and passes it to your server.

The same header opens all server requests — the /s2s/v1/* addresses. We take the project from the key; do not pass it separately. Keep the key on the server, never in app code.

What a key can do

Each request needs its key scope. A key gets all scopes except the last three in the table: the project owner opens those with toggles, see “Money from the app”. A toggle applies at once to all project keys, existing ones included.

ScopeRequestsNew key has it
entitlement:readGET /s2s/v1/entitlementyes
users:writePOST /s2s/v1/usersyes
identity:readGET /s2s/v1/identity/link-profile, POST /s2s/v1/identity/resolve-by-email, POST /public/identity/resolve-by-email with a keyyes
identity:writePOST /s2s/v1/identity/link-profileyes
user:readGET /s2s/v1/user/propertiesyes
user:writePOST /s2s/v1/user/propertiesyes
events:writePOST /public/event with a keyyes
subscription:managePOST /s2s/v1/subscription/cancel, /pause, /resume, /portalafter the toggle
billing:chargePOST /s2s/v1/subscription/charge, GET /s2s/v1/subscription/charge/statusafter the toggle
billing:refundPOST /s2s/v1/subscription/refundafter the toggle

Scopes cannot be picked at issue: the “Create a key” window asks only for name and mode.

Secret signing — for two public addresses

POST /public/event and POST /public/identity/resolve-by-email accept both a key and a body signature. Start with the key — it is shorter. Sign if there is nowhere to keep a key on that side.

With a key:

The secret is issued with “Issue” in the “Signing secret for inbound requests” row on the same tab and is also shown once. Keep it on the server, like the key: whoever knows it signs requests as the project. A signature has no scopes: the table above covers only key requests; the secret opens both addresses fully.

  1. Take the current time in milliseconds — this is timestamp.
  2. Build the string ${timestamp}.${rawBody}: the body byte for byte as sent, without re-serialising the JSON.
  3. Compute HMAC-SHA256 of it with the project secret and convert the result to hex.
  4. Send two headers: X-Signature: sha256=<hex> and X-Signature-Timestamp with the same time.
const ts = String(Date.now());
const sig = crypto.createHmac("sha256", PROJECT_SECRET)
  .update(`${ts}.${rawBody}`).digest("hex");
// headers: { "X-Signature": `sha256=${sig}`, "X-Signature-Timestamp": ts }

The signed body must contain projectId — it tells us whose secret checks the signature. The value is in the “Project ID” field on the same “For developers” tab. The time is compared with ours: over five minutes apart — refusal.

The timestamp here is in milliseconds, while in our event signatures to you it is in seconds: verification code does not carry over from one side to the other (Webhooks).

What a refusal looks like

Server requests refuse with the body { "error": { "type", "code", "message" } }. Branch on code: the message text may change.

ResponsetypeWhat happened
401authentication_errorthe key is missing, malformed, revoked, or an old key after rotation
403permission_errorthe operation is closed — code names the reason, table below
400, 409, 422invalid_request_erroran error in the request itself, including a parameter the request does not know, or a conflict — for example, the payment is already refunded
429rate_limit_errormore requests than the limit — table below
404not_foundthe resource does not exist or belongs to another project — one answer for both
5xxapi_errora failure on our side, code is also api_error — retry later

What code means on a 403:

codeReasonWhat to do
permission_errorthe key lacks the scope for this operationcheck against the scope table above
charge_disabled, refund_disabled, subscription_manage_disabledthe project owner has not turned on the charges, refunds or subscription management toggleask the owner to turn it on — “Money from the app”
test_key_not_alloweda charge or refund with a test keyissue a live key
bridge_tier_expiredthe project's paid plan is unpaid or switched to free, and the grace period has endedthe owner returns to a paid plan; keys need not be reissued
bridge_temporarily_unavailablea failure on our sideretry later; the plan is fine
project_frozen, project_readonlymoney and subscription management are stopped at the organization or project levelsee “Troubleshooting”

The two public addresses above differ: an unknown key or a bad signature gives a bare 404, never 401.

How many requests you can make

Above the limit you get 429. Each address has its own counter: a hundred entitlement checks do not eat the property-write limit.

Server requests count per key: all your servers with one key share a counter, and a new IP address does not reset it. Public addresses count per sender IP address, even with a key. Event intake is the exception: it counts the project's events.

AddressLimit
each /s2s/v1/* address120 a minute per key
GET /public/entitlement60 a minute per IP address
POST /public/event300 events a minute per project; from one IP address — up to 3000 requests of any kind and up to 60 failing the key or signature check
POST /public/identity/resolve-by-email60 a minute per IP address
POST /public/handoff/app-callback60 a minute per IP address
GET /public/handoff/resolve10 a minute per IP address
POST /public/handoff/resolve-by-fingerprint10 a minute per IP address
POST /public/handoff/email-recovery/request5 per 15 minutes per IP address; an email to one address — at most once per 20 minutes
POST /public/handoff/portal-entry/request5 per 15 minutes per IP address; an email to one address — at most once per 20 minutes

Rotation and revocation

For an access key both are in the “⋯” menu at the end of its row. “Rotate” issues a new key and accepts the old one for 72 more hours — time to roll out the server without downtime. The new key has its own rate limit. “Revoke” kills the key at once and for good. An old key marked “grace until …” has only “Revoke now” in its menu.

The signing secret changes with “Rotate” in its row; the old one is accepted for 48 more hours.

Next