Getting set up

Becoming a partner is three deliberate acts by an operator at Fluid, in this order. None of them is self-serve, and none of them can be triggered from an API you hold.

  1. A partner record is created — your slug, your display name.
  2. It is linked to an ordinary Fluid account you already own — the funding account.
  3. A key is issued — to you, once, in plaintext.

Step 2 is the one people skip and then spend a day debugging. A partner with no funding account is non-functional: it earns nothing, it can seed nothing, it can buy nothing, and its own panel does not appear. Everything still returns 200, which is exactly why it is worth checking first.

::: tip Ask for a SANDBOX key first — it comes with $5 of testing balance, on us. The same three steps, pointed at the sandbox. Your sandbox partner balance starts at $5 — once, not per account — and you spend it on whichever test accounts you choose with the ordinary credit call. Nothing is seeded automatically, because funding a customer is a call your integration has to make in production anyway and the sandbox should make you exercise it.

$5 of yours buys $6.25 for your customers, since you buy at your rate off. When it is gone, credit calls answer 402 insufficient_partner_balance — provisioning keeps working, accounts just arrive empty.

Nothing about the code changes when you move to production — same routes, same bodies, same errors, a different host and a different key. :::

1. The partner record

The operator calls, on a platform-admin session:

curl -X POST https://api-account.fluidvip.com/api/admin/partners \
  -H "Authorization: Bearer <platform-admin JWT>" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "acme",
    "name": "Acme Ltd",
    "contact_email": "[email protected]"
  }'
{
  "id": "0f6c2a41-7b3e-4a52-9c18-2d5b7e0a4411",
  "slug": "acme",
  "name": "Acme Ltd",
  "status": "active",
  "default_referral_slug": null,
  "contact_email": "[email protected]",
  "note": null,
  "created_at": "2026-08-25T09:12:44.108231+00:00"
}

slug is lowercased and must be 2-40 characters of letters, digits, - or _ — anything else is 400 invalid_slug, and a slug already in use is 409 slug_taken. It is stamped into the audit trail of every account you provision and cannot be changed afterwards; there is no slug field on the update route. Pick the one you want to live with.

contact_email is operations metadata. Nothing is ever mailed to it.

Creating a partner mints no key. That is a separate call, on purpose.

2. The funding account

You need an ordinary Fluid account — the same kind your customers have, created the same way at fluidvip.com. That account is you, commercially:

  • commission accrues into it, at your partner rate and with no expiry;
  • a starting balance you give a customer comes out of it;
  • balance you buy on a customer's behalf is charged to it;
  • your referral link belongs to it — anyone who follows that link and signs up on our own site is attributed to you, on the same terms as an account you provisioned;
  • signing into it is what makes your partner panel appear.

There is no partner login, no partner password, and no second kind of wallet anywhere in this feature.

Your referral link, your earnings and the payout button all live on that account's Affiliate tab. Billing and commission has the numbers.

You are invited to it; it is not assigned to you. The operator names an address, and you accept:

curl -X POST https://api-account.fluidvip.com/api/admin/partners/{partner_id}/invitations \
  -H "Authorization: Bearer <platform-admin JWT>" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]"}'

That returns an invite_urlonce, like every other credential here. You open it, sign in as that address, and accept. The account bound is the one you are signed in to; there is no field anywhere in this flow that names an account, so there is nothing for either side to mistype.

Refusal Meaning
403 invitation_email_mismatch you are signed in as a different address. Sign in as the invited one
409 owner_already_set this partner already has a funding account
409 owner_id_taken your account is already another partner's funding account
404 invitation_invalid unknown, expired, already accepted, or withdrawn — one answer for all four

Accepting requires the account owner. A team member or assistant on that account cannot sign it up to a commercial relationship, even though they can otherwise act for it.

owner_id is operator-only, permanently. It is the one field on the settings body you may never set. Sending it on your own routes is 403 owner_id_is_operator_only — a visible refusal, not a silent drop, because a partner able to choose which account money comes out of could point it at a stranger's balance. Everything else on that body is yours to tune.

One account can be the funding account of exactly one partner. A second attempt is 409 owner_id_taken.

3. Your key

A key can be minted two ways. Both return the plaintext exactly once.

By the operator, for your first key:

curl -X POST https://api-account.fluidvip.com/api/admin/partners/{partner_id}/keys \
  -H "Authorization: Bearer <platform-admin JWT>" \
  -H "Content-Type: application/json" \
  -d '{"label": "prod"}'

By you, from your own panel, on your ordinary session — this is how you rotate without a support ticket:

What <your Fluid session token> means. It is the access token your browser already holds after you sign in to fluidvip.com — the same session that renders your panel. Nothing about it constrains your own stack: it authenticates you, the person, to your Fluid account, and it is issued by our identity provider, not yours. Your server never needs it. That side of the integration uses X-Partner-Key, below, which is the credential you actually deploy.

curl -X POST https://fluidvip.com/api/me/partner-program/keys \
  -H "Authorization: Bearer <your Fluid session token>" \
  -H "Content-Type: application/json" \
  -d '{"label": "prod-2026-08"}'

Both answer 201:

{
  "id": "b81d0a55-6c9e-4f21-8a70-3e5c9d1b2f88",
  "partner_id": "0f6c2a41-7b3e-4a52-9c18-2d5b7e0a4411",
  "label": "prod-2026-08",
  "last4": "9Qv2",
  "prefix": "fv_pk_",
  "created_at": "2026-08-25T09:20:03.551907+00:00",
  "last_used_at": null,
  "revoked_at": null,
  "key": "fv_pk_yq3Rr8v1nJ2sKfWpD6bTgH0mZxA7eLc5uY4iO_9Qv2",
  "header": "X-Partner-Key"
}

The key field appears here and nowhere else, ever. Only its sha256 is stored. There is no recovery route, no "show key again", and no support path that can retrieve it — a database read, a backup, or a leaked dump is not enough to impersonate you. If you lose it, mint another and revoke the old one.

label is optional, at most 80 characters, and exists so you can tell two live keys apart without seeing either.

Rotating

Several live keys per partner is a supported state, and it is the whole reason rotation is issue → deploy → revoke rather than "revoke and hope":

# 1. issue
curl -X POST https://fluidvip.com/api/me/partner-program/keys \
  -H "Authorization: Bearer <your Fluid session token>" -d '{"label":"prod-next"}'

# 2. deploy the new key everywhere, confirm traffic on it

# 3. revoke the old one — idempotent
curl -X DELETE https://fluidvip.com/api/me/partner-program/keys/{key_id} \
  -H "Authorization: Bearer <your Fluid session token>"

Minting never revokes anything. Revoking takes effect on the next request presenting that key, which then answers 401 partner_key_invalid. Other live keys are untouched. The row survives with revoked_at set and is kept forever, so "which key was this?" stays answerable after an incident.

List what you have — metadata only, never a key:

curl https://fluidvip.com/api/me/partner-program/keys?include_revoked=true \
  -H "Authorization: Bearer <your Fluid session token>"

::: warning Revoking works while suspended; minting does not. If your partner record is suspended, POST /me/partner-program/keys answers 403 partner_suspended — suspension is the operator's kill switch and it should not be possible to keep issuing credentials through it. DELETE still works. A partner who believes a key has leaked must never have to wait for us. :::

Where the key goes

Your server. Only your server.

  • Never in your desktop application's bundle, however obfuscated.
  • Never in a mobile app.
  • Never in browser JavaScript, an .env a bundler inlines, or anything shipped to a device.
  • Never in a URL — not a query string, not a fragment, not a redirect.

The key names no account in any request, so the blast radius of a leak is bounded to accounts you provisioned. It is still enough to provision accounts as you, mint registration links for your customers, and spend your funding account's balance via purchases. Treat it as a payment credential.

This is also why /partner/* is unreachable through fluidvip.com — see Base URLs. If you find yourself wanting a browser to call it, the answer is always a call from your backend instead.

Confirm it works

The cheapest end-to-end proof, and the one to run after every rotation:

curl -i -X POST https://api-account.fluidvip.com/api/partner/accounts \
  -H "X-Partner-Key: fv_pk_…" \
  -H "Content-Type: application/json" \
  -d '{"external_ref": "smoke-test-1"}'

201 means the key is live and the plane is open. It is idempotent on external_ref, so running it twice is safe — the second call answers 200 with the same account.

Anything else, in the order you are likely to see it:

Response Meaning
503 provisioning_not_configured the plane is off in this environment, or no partner key exists at all. Not something you can fix — ask your Fluid contact
401 partner_key_invalid unknown, revoked, or malformed key. All three answer the same on purpose
403 partner_suspended the key is fine; your partner record is suspended
429 over your per-partner rate limit — see Limits

Spending your $5 sandbox balance

Your sandbox partner balance starts at $5. That is the whole allowance — once, for the partner, not per account — and you decide which test accounts get it. Nothing is funded automatically.

Why you move it yourself

Funding a customer is a call your integration has to make in production anyway. If we simply put balance into every account you created, the most billing-sensitive path in your code would stay untested until a real customer hit it. Spending the allowance is the test.

What $5 is worth

amount_usd on the credit call is what the account receives, not what you pay. You are charged 20% less, so your $5 reaches further than $5:

amount_usd you send The account receives You are charged
1 $1.00 $0.80
2 $2.00 $1.60
5 $5.00 $4.00
6.25 $6.25 $5.00 — your whole allowance

::: tip 20% off is a 25% uplift. Both numbers are right. Paying 20% less means paying 80%, and 1 ÷ 0.8 = 1.25 — so what you spend stretches by a quarter. A 20% discount and a 25% bonus are the same transaction from opposite ends. It is neither "$5 − 20% = $4" nor "$5 + 20% = $6".

And the discount is not a gift: it is your commission, taken at the till. Had your customer bought the same $6.25 from us directly, we would have taken $6.25 and paid you $1.25 afterwards. Either way we keep $5.00 and they hold $6.25 — which is the whole two-path model, seen here on our money instead of yours. :::

At the metered rate a conversational turn costs on the order of a tenth of a cent, so $6.25 is several thousand replies. You are far more likely to finish integrating than to run out.

A worked pass, end to end

1. Provision a test account. Free — this touches no model and costs nothing.

curl -X POST https://api-green-account.fluidvip.com/api/partner/accounts \
  -H "X-Partner-Key: fv_pk_…" \
  -H "Content-Type: application/json" \
  -d '{"external_ref": "sandbox-alpha", "display_label": "Sandbox — Alpha"}'

Keep the owner_id. It is the account, and every call below names it.

2. Give it some of your $5. amount_usd is what the ACCOUNT receives; you are charged 20% less.

curl -X POST https://api-green-account.fluidvip.com/api/partner/accounts/{owner_id}/credit \
  -H "X-Partner-Key: fv_pk_…" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd": 2, "reference": "sandbox-alpha-topup-1"}'
{
  "face_usd": 2.0,
  "charged_usd": 1.6,
  "discount_usd": 0.4,
  "rate_pct": 20.0,
  "partner_balance_usd": 3.4
}

Read partner_balance_usd — that is your allowance draining in real time, and it is the number to watch while you test. charged_usd is what left your side; face_usd is what arrived on theirs.

::: warning reference is an idempotency key, and this is the best possible place to learn that. Send the same reference twice and the second call is a replay: 200, the original row, "replayed": true, and no second charge. Send a reference differing by one character — a stray space, a bumped counter you did not mean to bump — and it is a second, real purchase.

Prove it here, against $5 of our money, rather than in production against a customer's. :::

3. Configure a character and drive a conversation with the same key plus X-Partner-Account — see Acting for a customer. Every reply now bills the test account's balance, not yours, exactly as a real customer's would.

4. Watch both numbers move. The account's balance falls as it chats; yours only falls when you top it up again. That split — your balance versus theirs — is the whole commercial model, and this is the cheapest place to see it behave.

Splitting it across accounts

Nothing forces the allowance into one account. A realistic shape:

Account Face value Costs you For
sandbox-alpha $4.00 $3.20 the account you actually build against
sandbox-beta $1.00 $0.80 proving two accounts stay isolated
sandbox-empty leave it at $0 to see how your code handles a broke customer

That third one is worth doing deliberately. An account with no balance is a state your integration will meet in production, and it is far cheaper to meet it on purpose here.

When the $5 is gone

{"detail": {"error": "insufficient_partner_balance",
            "message": "Your balance does not cover this purchase."}}

402, and nothing moved — not a partial charge. Provisioning, registration links and account reads keep working, because none of them touch money; new accounts simply arrive empty.

If you genuinely need more to finish, ask. It is a number, not a policy.

Your panel

Signing into your funding account at fluidvip.com shows a Partner tab with your customers, your earnings, your keys, and your seeding settings. Its read is one call:

curl https://fluidvip.com/api/me/partner-program \
  -H "Authorization: Bearer <your Fluid session token>"

An account that is not a partner gets 200 {"is_partner": false} — not an error, because that is the ordinary answer for almost everybody. If you are the partner and still see it, the funding account is not linked; go back to step 2.

The panel is owner-only. A team member or assistant on your funding account gets 403 on every route here and 404 not_a_partner on the settings write. An assistant must not be able to mint a credential that provisions accounts.