POST https://api-account.fluidvip.com/api/partner/accounts
One call, one row, no human. No email, no password, no signup form, no confirmation mail, and no call to our identity provider at all. You send your own reference for the customer; you get back an account id that works everywhere in the ecosystem.
Authentication.
X-Partner-Key, on your server. The partner is resolved from the key — there is no field on this body that names an account or another partner, so there is nothing in the request to tamper with.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
external_ref |
string | Yes | Your own id for this customer. 1-128 chars matching ^[A-Za-z0-9._:-]+$. This is the idempotency key — see below |
display_label |
string | No | A human label for your own dashboards. Max 120 chars |
referral_code |
string | No | Attribute this account to a specific referral link instead of your default. Max 64 chars |
metadata |
object | No | Opaque JSON you can read back. Max 4096 bytes serialised |
curl -X POST https://api-account.fluidvip.com/api/partner/accounts \
-H "X-Partner-Key: fv_pk_…" \
-H "Content-Type: application/json" \
-d '{
"external_ref": "cust-8842",
"display_label": "Nadia R. (Studio plan)",
"metadata": {"tenant": "studio-eu-3", "plan_hint": "pro"}
}'
Response
201 Created on a first provision, 200 OK on an idempotent replay. The body is identical.
{
"owner_id": "9c1f4b7e-2a58-5d03-b6e1-77af0c9d3218",
"external_ref": "cust-8842",
"state": "provisioned",
"created_at": "2026-08-25T09:41:17.882014+00:00",
"registered_at": null,
"registration_link_outstanding": false,
"metadata": {"tenant": "studio-eu-3", "plan_hint": "pro"},
"referral": {"bound": true, "reason": "bound"},
"seed": {"amount_usd": 0.0, "reason": "disabled"}
}
| Field | Type | Description |
|---|---|---|
owner_id |
string | The account id. Store this. It is what every other call in the ecosystem keys on |
external_ref |
string | Echoed back, unchanged |
state |
string | provisioned until the human claims it, then registered |
created_at |
string | ISO 8601 |
registered_at |
string | null | When the human claimed it, or null |
registration_link_outstanding |
boolean | Whether a registration link is currently live for this account |
metadata |
object | null | Exactly what you sent |
referral |
object | {bound, reason} — commission attribution. See Billing |
seed |
object | {amount_usd, reason} — the starting balance, if you have one configured |
There is deliberately no email, no balance, and no plan on this response. There is no email before registration and after it the address belongs to the human; balance and plan live behind a different credential, and provisioning must not become a second path to money data.
Idempotency is on your reference
The account id is derived from external_ref, deterministically. Send the same external_ref twice
and you get the same account back, with 200 instead of 201:
# first call -> 201, owner_id 9c1f4b7e-…
# second call -> 200, owner_id 9c1f4b7e-… (identical)
This is what makes the call safe to retry when you never saw the answer — a timeout, a dropped connection, a redeployed worker. Retry it; you cannot accidentally create a second account.
Two consequences worth planning around:
- A stray space or a different case is a different customer.
cust-8842andcust-8842are not the same reference; the charset pattern rejects the space outright, which is the point. Normalise your references before you send them. - The mapping is permanent. You cannot re-point an
external_refat a different account, and deleting the relationship does not free the reference — see revocation.
The id is not guessable. It is a UUIDv5 derived under a per-partner secret salt that is on no response, no screen, and no endpoint. Another partner who knows your customer numbering scheme still cannot compute your account ids.
What you can do with the account immediately
The moment this returns, the account is real. It has a wallet, it can hold entitlements, and every product in the ecosystem keys on that plain string. You can fund it, read its entitlements, and build against it.
What it cannot do is anything user-facing, because there is no identity record and therefore no session anyone can hold. That is the design: an unregistered account is operated entirely through you.
Provisioning cannot fail because our identity provider is down. This route makes no call to it — it allocates an id and writes one row. That is a real operational property and it belongs in your planning: your onboarding never blocks on our authentication being healthy.
The starting balance
A provisioned account starts at $0.00.
The ordinary $1 signup grant is not applied here. It belongs to the moment a human first authenticates, which is also the only version of it you cannot farm by looping this call. Your customer does receive it — at their first login, after they claim the account.
If you want them to start with something, you can configure a seed that moves money from your own
funding account. It is off by default and capped several ways; see
Billing → the starting balance. The seed.reason on
this response tells you exactly what happened:
seed.reason |
Meaning |
|---|---|
seeded |
moved; amount_usd is what the customer received |
disabled |
you have not turned seeding on. The default |
already_seeded |
this account was seeded before. One seed per account, ever |
no_funding_account |
your partner record has no funding account linked |
zero_amount |
seeding is on but the amount is 0 |
no_total_budget / no_daily_budget |
seeding is on but a budget is unset. Unset means refuse, never "unlimited" |
total_budget_exhausted / daily_budget_exhausted |
you have spent the budget you set |
daily_account_limit |
over the operator's rolling-24h cap on seeded accounts |
insufficient_partner_balance |
your funding account does not cover it |
self_seed |
the beneficiary is your own funding account |
error |
something went wrong seeding. The account was still created |
::: tip A seed can never fail a provision.
Every value in that table except seeded still comes with a 201 and a working account. The account
is the valuable thing; the seed is a courtesy. If your customers are arriving empty, read
seed.reason — it always says why, and "nothing happened" is never the answer.
:::
Reading accounts back
One account, by the id you were given:
curl https://api-account.fluidvip.com/api/partner/accounts/9c1f4b7e-2a58-5d03-b6e1-77af0c9d3218 \
-H "X-Partner-Key: fv_pk_…"
Your accounts, newest first:
curl "https://api-account.fluidvip.com/api/partner/accounts?limit=100" \
-H "X-Partner-Key: fv_pk_…"
limit is 1-500, default 100.
{
"items": [
{
"owner_id": "9c1f4b7e-2a58-5d03-b6e1-77af0c9d3218",
"external_ref": "cust-8842",
"state": "provisioned",
"created_at": "2026-08-25T09:41:17.882014+00:00",
"registered_at": null,
"registration_link_outstanding": false,
"metadata": {"tenant": "studio-eu-3", "plan_hint": "pro"}
}
]
}
The partner id comes from the key, so these only ever return accounts you provisioned. An account
belonging to someone else — or one whose owner has revoked you
— is 404, never 403. "Not yours" and "does not exist" are deliberately indistinguishable, and
revoked accounts vanish from the list entirely.
When a customer leaves
There is no partner-facing delete, deliberately. No route you hold removes an account, empties
one, or releases its external_ref.
That is not an omission. The account is your customer's property from the moment it exists — they may claim it later, it may hold balance they or you paid for, and it may be the only copy of their character. A credential that could delete it is a credential that can destroy a customer's data on a bad deploy or a stolen key. So the capability is absent rather than guarded, which is a stronger guarantee than a permission check.
What to do instead:
| You want to | Do this |
|---|---|
| Stop billing and stop using the account | just stop calling. An idle account costs you nothing — there is no per-account fee |
| Remove it from your own product | drop your side of the mapping. Keep the owner_id if you might ever need to reconcile a payout |
| Hand it over for good | mint them a registration link. Once they claim it, it is theirs and they can disconnect you from Connected apps |
| Be sure you can no longer act on it | ask your Fluid contact. Disconnection is the customer's to give, and an operator's — never yours to take |
An account whose owner has revoked you
disappears from your list and answers 404 on every read, as though it had never been yours. Your
already-earned commission on it is untouched.
::: warning external_ref is never released, and re-provisioning a revoked account looks like success.
Two behaviours worth planning around, both of which return 200 rather than an error:
Never recycle a reference. It stays mapped to its account id forever. Sending a retired
external_ref for a different customer returns the old account — to us it is indistinguishable
from a retry, so there is no error to catch, and your new customer would be handed your old
customer's character.
Provisioning is the one route that does not hide a revoked account. Reads and writes on a
revoked account are 404 and it is gone from your list, but re-sending its external_ref replays
the original row with a 200. That does not restore your access — every other call still
404s. A 200 from this route is therefore not proof you can act on the account; the read is.
:::
Errors
| Status | error |
When |
|---|---|---|
409 |
no_funding_account |
the most likely error on your very first call. Your partner record has no funding account linked yet. Nothing was written — link the account and re-send the same external_ref |
422 |
(FastAPI validation) | external_ref missing, too long, or containing a character outside [A-Za-z0-9._:-] |
422 |
invalid_metadata |
metadata is not JSON-serialisable |
422 |
metadata_too_large |
metadata exceeds 4096 bytes serialised |
401 |
partner_key_invalid |
missing, unknown, or revoked key |
403 |
partner_suspended |
valid key, suspended partner |
429 |
rate_limited |
over your per-partner cap — see Limits |
503 |
provisioning_not_configured |
the plane is off in this environment |
The 409 is a refusal rather than a warning on a 201 on purpose, and it is worth knowing why
before you code around it. An account provisioned with no funding account is attributed to nobody,
and attribution is write-once — so if anything else binds that account first, your commission on
that customer is gone permanently and no later write can recover it. Refusing before the insert
means there is nothing stranded and the retry is free.
Errors carry the shape used across this service:
{"detail": {"error": "metadata_too_large", "message": "metadata must be at most 4096 bytes."}}
Related
- The registration link — handing the account to its human
- Billing and commission — attribution, seeding, and buying balance
- Limits and failures — rate limits and what fails closed