Creating a customer account

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-8842 and cust-8842 are 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_ref at 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."}}