Run on a user's ChatGPT subscription
This page is for an app that builds on Fountain and wants its users' codex
agents to run on the users' own ChatGPT subscriptions. It says how the
feature works from the API, what your app must do, and how to verify that
your integration does it. The console does all of this on one page; this is
the same thing over /api.
Status
On the hosted platform this is on for every account since 2026-09-21. On a self-hosted instance it is off unless the operator forces the
chatgpt_subscriptionsflag. Parts of it have not yet been used against the real service; see feature status.
The model, in four sentences
A user links a ChatGPT subscription by a device-code sign-in; Fountain keeps the tokens and renews them, and no route ever returns one. A subscription is a named grant, and a user may hold several. A grant does nothing until a credential set names it. A codex agent that uses that set then runs on that subscription, and on nothing else: if the subscription is unusable, the run fails with a reason rather than falling back to a key or to the platform's account.
The last sentence is the one apps get wrong. Linking a subscription does not change what any agent uses. A user who links one and then runs an agent sees that agent use whatever it used before, which on the hosted platform is the deployment's own account when the user has no OpenAI key. That is not a bug. The set is the switch.
The three steps your app drives
Every route below needs a full-scope account key. A sprite key or an
OAuth client with less than full scope gets 403 insufficient_scope. An
id that belongs to another account is 404.
1. Link: a device-code sign-in
curl -X POST "$FOUNTAIN/api/account/chatgpt-subscriptions/attempts" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "Work"}'
The reply is an attempt: id, state pending, user_code,
verification_url, poll_interval, expires_at. Show the person the
user_code and a link to verification_url, taken from the reply and from
nowhere else. Then poll:
curl "$FOUNTAIN/api/account/chatgpt-subscriptions/attempts/$ATTEMPT_ID" \
-H "Authorization: Bearer $KEY"
every poll_interval seconds. Fountain polls ChatGPT; your read contacts
nobody. state moves once, from pending to completed, cancelled,
expired or failed, and user_code is null after that. A completed
attempt has the new subscription's id in result_grant_id. A failed one has
failure.reason; for account_already_linked it also names the subscription
that already holds that ChatGPT account, which is the one to reconnect
instead. An attempt expires after fifteen minutes.
Show the code only to the person who started the attempt, and tell them to enter a code only if they started it themselves. A person who enters someone else's code links their subscription to that other account.
Limits: three open attempts per account, one per subscription for a
reconnect, ten starts an hour. The refusals are 409 chatgpt_link_attempts_exceeded, 409 chatgpt_link_attempt_pending (with
the open attempt_id), 409 chatgpt_grant_limit_reached (with count and
limit) and 429 chatgpt_link_attempts_rate_limited (with Retry-After).
Where linking is off, a new-subscription attempt is 404 chatgpt_subscriptions_not_enabled; GET /api/auth/me reports the door as
chatgpt_subscriptions_enabled, so check it before showing a "Connect"
button.
2. Name it on a credential set
curl -X PATCH "$FOUNTAIN/api/account/inference-credential-sets/$SET_ID" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"chatgpt_grant_id\": \"$RESULT_GRANT_ID\"}"
The set now reports chatgpt_grant with the subscription's name and
status. Send null to clear it. Naming a grant on a set ends that set's
running codex conversations with 409 inference_source_changed; do it
before launching, or warn the user.
Which set? An agent uses its own inference_credential_id, else the
account's default set. If your app manages one agent per user, name the
grant on the set that agent uses. GET /api/account/inference-credential-sets
lists them, default first. An account always has a default set.
Only codex runs use the grant. Any other OpenAI consumer on the same set
(opencode on an openai/ model, for example) still needs an
openai_api_key in the set.
3. Run, and read what served the turn
Launch a conversation on an agent whose set names the grant, on the codex
runtime with an openai/… model. Each turn then carries a read-only
inference object:
"inference": {"origin": "own", "scope": "grant", "chatgpt_grant_id": "…"}
origin is own for the user's credential and platform for the
deployment's; scope is grant when a subscription served the turn.
Fountain writes it when the turn starts and never changes it, so a later
change to the set relabels later turns only. This object is your proof.
A turn that ran on the platform account reads origin: "platform", and one
on the set's own key reads scope: "credential".
What "no fallback" looks like to your app
A named subscription that is disconnected, revoked, expired, reconnect- required, exhausted or removed refuses the run instead of substituting anything:
- Launch:
409 chatgpt_grant_unusablewithreason,grant_id,grant(the name),until(set when the reason isexhausted) and amessagethat ends "start a new conversation". - An existing conversation: the turn is refused, and the stream's
admission_refusedstage event carries the same fields.
Handle it by telling the user which subscription and why, and offering the
fix: reconnect it (a new attempt with {"grant_id": …}), clear it from the
set, or wait for until. Do not retry on another credential for them; that
is the decision Fountain deliberately does not make.
Exhaustion is detected only after a turn fails: the failing turn shows
codex's own message, Fountain then confirms the limit with OpenAI (at most
once every five minutes per subscription), and every later launch or turn
gets reason: "exhausted" with the reset time in until. The subscription
list shows exhausted_until too.
Managing subscriptions
| Route | Does |
|---|---|
GET /api/account/chatgpt-subscriptions |
List, with count, limit, linking_enabled. Each has id, name, status, plan_type, account_email, refreshable, exhausted_until, last_refreshed_at. Never a token. |
PATCH …/:id {"name"} |
Rename. |
POST …/attempts {"grant_id"} |
Reconnect: a new sign-in for an existing subscription. The old credential keeps serving until the new one is stored. |
POST …/:id/disconnect |
Forget the tokens, keep the row. Sets that name it start refusing. |
DELETE …/:id |
Remove a disconnected subscription that no set names (409 chatgpt_grant_named_by_sets otherwise). |
Fountain renews an idle subscription about every six days. Fountain cannot revoke a sign-in at OpenAI; a user who wants that signs the device out there.
How to verify your integration
Run this once against a test account, in order. Each step has one observable that proves it.
- Door.
GET /api/auth/me→chatgpt_subscriptions_enabled: true. - Link. Create an attempt, approve the code in a browser, poll to
completed. Proof: the subscription appears in the list withstatus: "active". - Not yet in use. Launch a codex conversation on the agent, send a
prompt. Proof: the turn's
inference.originis notownwithscope: "grant"(on the hosted platform it isplatform). This is the step that proves you understand the model. - Name it.
PATCHthe agent's set withchatgpt_grant_id. Proof: the set reportschatgpt_grant.name. - In use. Launch a new conversation, send a prompt. Proof:
inferencereads{"origin": "own", "scope": "grant", "chatgpt_grant_id": <the id>}. - No fallback. Disconnect the subscription, then launch again. Proof:
409 chatgpt_grant_unusablewithreason: "disconnected"and the name, even though the platform account (or a key) would have worked. Your UI should show the reason, not a generic error. - Reconnect. Start an attempt with
{"grant_id"}, approve, poll tocompleted. Proof: the list showsactiveagain and a new launch runs on it. - Two subscriptions, if you support them. Link a second one under
another name, name it on a second set, put two agents on the two sets.
Proof: each agent's turns carry its own
chatgpt_grant_id, and disconnecting one refuses only that agent's launches.
What you cannot verify from the API, and do not need to: the token is never returned, and the sandbox never holds it. The broker attaches it to the one route Codex uses and to nothing else.
Two things to get right in the UI
- After a successful link, if the user holds exactly one subscription and the relevant set names nothing, offer to name it right there. Without that offer, step 3 above is what your users experience and they will read it as broken.
- A user who leaves to approve the code and comes back on another page
needs to see the outcome.
GET …/attemptslists pending attempts only, so keep each attempt'sidon your side and readGET …/attempts/:idon return: an ended attempt stays readable by id, with itsstateandfailure.
See also the API guide for every refusal,
the console guide for what a user sees,
and the generated reference at /api/docs for the schemas.