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.
- Only the project owner or admin can issue, rotate and revoke keys. A member? Ask the owner for the admin role: “Project and team”, “Hand off to a developer”.
- The section works on paid plans; the free plan issues neither a key nor a signing secret.
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.
| Scope | Requests | New key has it |
|---|---|---|
entitlement:read | GET /s2s/v1/entitlement | yes |
users:write | POST /s2s/v1/users | yes |
identity:read | GET /s2s/v1/identity/link-profile, POST /s2s/v1/identity/resolve-by-email, POST /public/identity/resolve-by-email with a key | yes |
identity:write | POST /s2s/v1/identity/link-profile | yes |
user:read | GET /s2s/v1/user/properties | yes |
user:write | POST /s2s/v1/user/properties | yes |
events:write | POST /public/event with a key | yes |
subscription:manage | POST /s2s/v1/subscription/cancel, /pause, /resume, /portal | after the toggle |
billing:charge | POST /s2s/v1/subscription/charge, GET /s2s/v1/subscription/charge/status | after the toggle |
billing:refund | POST /s2s/v1/subscription/refund | after 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 event body still needs the
projectIdfield — without it you get400; - ask for the identifier by email at
POST /s2s/v1/identity/resolve-by-email: the answer is unwrapped and refusals are distinguishable — “Check the entitlement”, section “If the server has only the email”.
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.
- Take the current time in milliseconds — this is
timestamp. - Build the string
${timestamp}.${rawBody}: the body byte for byte as sent, without re-serialising the JSON. - Compute
HMAC-SHA256of it with the project secret and convert the result to hex. - Send two headers:
X-Signature: sha256=<hex>andX-Signature-Timestampwith 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.
| Response | type | What happened |
|---|---|---|
401 | authentication_error | the key is missing, malformed, revoked, or an old key after rotation |
403 | permission_error | the operation is closed — code names the reason, table below |
400, 409, 422 | invalid_request_error | an error in the request itself, including a parameter the request does not know, or a conflict — for example, the payment is already refunded |
429 | rate_limit_error | more requests than the limit — table below |
404 | not_found | the resource does not exist or belongs to another project — one answer for both |
5xx | api_error | a failure on our side, code is also api_error — retry later |
What code means on a 403:
code | Reason | What to do |
|---|---|---|
permission_error | the key lacks the scope for this operation | check against the scope table above |
charge_disabled, refund_disabled, subscription_manage_disabled | the project owner has not turned on the charges, refunds or subscription management toggle | ask the owner to turn it on — “Money from the app” |
test_key_not_allowed | a charge or refund with a test key | issue a live key |
bridge_tier_expired | the project's paid plan is unpaid or switched to free, and the grace period has ended | the owner returns to a paid plan; keys need not be reissued |
bridge_temporarily_unavailable | a failure on our side | retry later; the plan is fine |
project_frozen, project_readonly | money and subscription management are stopped at the organization or project level | see “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.
| Address | Limit |
|---|---|
each /s2s/v1/* address | 120 a minute per key |
GET /public/entitlement | 60 a minute per IP address |
POST /public/event | 300 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-email | 60 a minute per IP address |
POST /public/handoff/app-callback | 60 a minute per IP address |
GET /public/handoff/resolve | 10 a minute per IP address |
POST /public/handoff/resolve-by-fingerprint | 10 a minute per IP address |
POST /public/handoff/email-recovery/request | 5 per 15 minutes per IP address; an email to one address — at most once per 20 minutes |
POST /public/handoff/portal-entry/request | 5 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
- Check the entitlement — the first request with this key and the answer field by field.
- Money from the app — charges, refunds and subscription management: three scopes a key lacks by default.
- Troubleshooting — the key is right, but the server still refuses.