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.
- A partner record is created — your slug, your display name.
- It is linked to an ordinary Fluid account you already own — the funding account.
- 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_url — once, 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_idis operator-only, permanently. It is the one field on the settings body you may never set. Sending it on your own routes is403 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 tofluidvip.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 usesX-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
keyfield 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
.enva 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
403on every route here and404 not_a_partneron the settings write. An assistant must not be able to mint a credential that provisions accounts.
Related
- Creating a customer account — the first thing you do with the key
- Limits and failures — every error this plane returns
- Billing and commission — what the funding account is actually for