Everything in the partner plane fails closed. A guard that is unsure refuses, an unset budget
refuses rather than meaning "unlimited", and a missing configuration is a 503 rather than a pass.
This page is the complete list of what can refuse you and why.
Errors carry one shape throughout:
{"detail": {"error": "partner_key_invalid", "message": "Missing or invalid X-Partner-Key."}}
Authentication
Every /api/partner/* call resolves your key first. Four outcomes:
| Status | error |
Meaning |
|---|---|---|
503 |
provisioning_not_configured |
the plane is off in this environment, or no partner key exists at all anywhere. Not something you can fix from your side |
401 |
partner_key_invalid |
header absent, or the key is unknown, malformed, or revoked. All of these answer identically — telling a prober that a key exists but is revoked tells them a key exists |
403 |
partner_suspended |
the key is valid; your partner record is suspended. Distinguishable on purpose — you already proved possession of a live key, so you learn nothing you did not have |
429 |
rate_limited |
over your per-partner rate limit. Carries a Retry-After header |
Revocation takes effect on the next request. There is no propagation delay and no cache. Other live keys are unaffected, which is what makes rotation safe — see Rotating.
Suspension is the operator's kill switch and it is reversible. It destroys nothing: accounts you already provisioned keep working, because they are ordinary accounts and were never your property. While suspended you can still revoke keys; you cannot mint them.
Failed-auth throttling
Failed authentications are counted per IP: 40 failures per 60-second window by default. Over
the cap you get 429 with a Retry-After, and the cap is checked before the database is touched
— key-spraying cannot be amplified. A successful authentication clears your IP's history, so one
mistyped key never penalises you.
Rate limits
| Surface | Default | Keyed on | Window |
|---|---|---|---|
| The whole partner plane | 120 requests/minute | your partner id, not your IP | 60s |
GET /api/register/{token} (pre-flight) |
30/minute | the end user's IP | 60s |
POST /api/register |
10/minute | the end user's IP | 60s |
HTTP/1.1 429 Too Many Requests
Retry-After: 27
{"detail": {"error": "rate_limited",
"message": "Rate limit exceeded for this partner key; slow down and retry shortly."}}
Being keyed on the partner rather than the address is deliberate: you are a server, your address is stable and shared by all your customers, and an IP bucket would be either useless or an outage.
::: tip The partner cap is a runaway-loop guard, not a business limit.
Provisioning is a bulk, scripted operation by design — an integrator onboarding a customer base does
thousands in an afternoon. If you are hitting 120/minute in normal operation, back off on
Retry-After and keep going; you are not doing anything unsupported.
:::
The registration caps are tighter than the pre-flight for a reason: every call that gets past the token check reaches our identity service, so this is also what stops a leaked registration URL becoming a stream of upstream writes while someone guesses passwords.
Size and shape limits
| What | Limit |
|---|---|
external_ref |
1-128 chars, ^[A-Za-z0-9._:-]+$ |
display_label |
120 chars |
referral_code |
64 chars |
metadata |
4096 bytes serialised. 422 metadata_too_large |
Purchase reference |
1-128 chars, same charset as external_ref |
Purchase note |
200 chars |
Key label |
80 chars |
Partner slug |
2-40 chars, letters/digits/-/_ |
limit on list endpoints |
1-500, default 100 |
| Accounts shown in your panel | 200 — use GET /api/partner/accounts for the complete list |
The metadata cap is generous for the plan hints and tenant ids a partner actually stores, and small
enough that the column cannot become free storage on a write we do not charge for.
Money limits
| Guard | Default | Owned by |
|---|---|---|
| Per-call purchase ceiling | $2,000 | operator |
| Rolling-24h purchase ceiling | $10,000 | operator — you cannot raise it |
| Per-account seed ceiling | $25 | operator |
| Seeded accounts per rolling 24h | 200 | operator |
| Total seed budget | you set it. 0 refuses | you |
| 24h seed budget | you set it. 0 refuses, ≤ total | you |
| Your funding account balance | — | reality |
The two velocity limits are operator-owned on purpose: the scenario they cover is your own credential is what went wrong, and a limit a compromised credential can raise is not a limit.
Every one of these is enforced on spend as well as on write, so a settings row edited directly in the database still cannot over-spend. Full refusal tables are in Billing.
An empty wallet refuses rather than going negative. The debit is all-or-nothing. An under-funded partner seeds nothing and buys nothing; nothing is ever partially applied.
Your customer can revoke you
An account you provisioned was never your property. Once its owner has registered it, their settings page shows a Connected apps section naming you, with a Revoke access button.
After they use it:
| What | Before | After |
|---|---|---|
GET /api/partner/accounts/{owner_id} |
200 |
404 account_not_found |
GET /api/partner/accounts |
listed | absent entirely |
POST …/credit |
works | 404 |
POST …/registration-link |
works | 404 |
| Your panel's customer list | listed | the row disappears |
| Their account, characters, balance and data | theirs | unchanged, still theirs |
::: warning It is a 404, not a 403, and you are not told the account still exists. That is deliberate and it is the same posture as every id-scoped lookup here: "not yours" and "does not exist" must be indistinguishable, because account ids are derived from references you supply and a partner guessing another partner's references is the exact attack.
The practical consequence: a 404 on an account you are certain you provisioned means one of two
things and you cannot tell which from the API — the customer revoked you, or you have the wrong id.
Only an operator can see the difference. If a customer's integration stops working with no other
explanation, that is the first thing to ask us about.
:::
The row is not deleted. That means the external_ref is not freed — re-provisioning it returns
the same (now severed) account rather than creating a new one, because the id derivation is
deterministic. If a customer comes back after revoking, they reconnect from your end; you do not
re-provision.
Revoking twice is a 200. An account with no partner link answers the revoke with
404 no_partner_link, which is honest rather than a silent success.
What is deliberately not available to you
Not oversights. Each is a decision, and asking for it will get the same answer:
| You cannot | Why |
|---|---|
| See a customer's email address, ever | there is none before registration, and after it the address is theirs |
| See a customer's balance or plan through the partner plane | those live behind a different credential; provisioning must not become a second path to money data |
| Read or spend any account's balance with your partner key | the key resolves to a partner and nothing else. It names no account in any request |
| Set your own funding account | 403 owner_id_is_operator_only. A partner who could choose which account money comes out of could drain a stranger's |
| Rename or unsuspend yourself, or change your slug | the slug is stamped into the audit trail of every account you provisioned |
| Re-mint a registration link for a registered account | 409 already_registered, permanently |
| Rotate your provisioning salt | rotating it would silently start minting a second account for every reference you ever provisioned. If a salt is believed compromised the answer is a new partner |
| Delete an account or a key row | keys keep a revoked_at; accounts keep the relationship. Both stay answerable after an incident |
| Request a payout from the partner API | that is the Affiliate tab, on the same account and the same money |
Idempotency, in one place
Three writes are idempotent, each on a key you choose. Retry all three freely.
| Call | Key | Replay behaviour |
|---|---|---|
POST /partner/accounts |
external_ref |
200 instead of 201, same account |
POST …/credit |
reference |
200, the original row, "replayed": true |
DELETE …/keys/{id} |
the key id | 200, unchanged timestamp |
Seeding is idempotent independently: one seed per account, ever, enforced by a uniqueness constraint regardless of how many times you retry the provision.
Nothing else is. In particular, minting a registration link is not idempotent — each call replaces the previous token.
The gaps, stated plainly
::: danger The sandbox is isolated, not simulated. There is no test mode, no test key prefix, no simulated billing and no dry-run anywhere. Every conversation spends real money at real prices, in the sandbox as much as in production — the difference is that we fund the sandbox, within caps, so evaluating us costs you nothing.
Provisioning, registration links, account reads and purchases are free in both — they touch no model — so you can exercise the whole account lifecycle at no cost anywhere. It is only conversations that charge. In production, keep your integration testing on one account you watch. :::
::: warning A partner context cannot reach FluidCloud. The connected-app credential makes you the owner of an account inside FluidTalk, but it carries no user assertion that can be replayed to FluidCloud. Anything that needs a Cloud tenant refuses — including Cloud-backed photo features.
This is a real boundary, not a switch waiting to be flipped. If your product depends on those features, your customer has to claim the account and use them signed in. Raise it with your Fluid contact before you commit to a launch date. :::
Related
- Getting set up — keys, rotation, and confirming the plane is open
- Billing and commission — every money guard in detail
- The config panel — the caps that apply inside the embedded panel
- Errors and Rate limits — the Characters API's own contracts