CLI OAuth
Flint registers flint-cli as a public OAuth client, with no client secret or HTTP Basic authentication. Login uses OAuth 2.0 Device Authorization Grant, renewal uses the refresh token grant, and logout uses token revocation. A person approves access in the Flint dashboard, and the client receives short-lived access tokens without handling an API key.
The Flint CLI runs these calls for flint login, flint context, flint reauth, and flint logout, and the HTTP contract here is what they rely on. For other jobs, use an API key on your server, a workload identity in CI, or partner OAuth for an app that merchants install.
Sessions, contexts, and tokens#
One browser approval creates a session. A session holds one or more contexts, and each context is one merchant in one mode: its live account, or one of its sandboxes. The person who approves chooses the contexts and the scopes each one holds.
| Credential | Prefix | Lifetime | Use |
|---|---|---|---|
| Device code | fcdc_ | 10 minutes, single use | Redeem it for the first tokens once the person approves. Keep it private. |
| User code | None, such as WDJB-MJHT | Same as its device code | Show it in your terminal. The browser shows the same code so the person can check they match. |
| Access token | fcat_ | 15 minutes at most | Call the API in exactly one context. |
| Refresh token | fcrt_ | Until the session ends | Mint an access token for any context in the session. Every use replaces it. |
The session keeps its oauth_session_id through every refresh and consent change. Each context has a context_id whose merchant, mode, and sandbox never change. If a consent review removes a context and a later review in the same session approves that merchant and sandbox again, the context returns with its original ID.
A session ends 30 days after its last token was issued, 90 days after login, when any of its tokens is revoked, or when a spent refresh token is reused. Ending the session invalidates every token in every context.
Note: Access follows the person who approved
Approving a context requires permission to manage that merchant's API keys. Flint checks the approver's access again on every API request and every refresh. If they leave the merchant or lose that permission, the context stops working in every session they approved.
Requests and responses#
These rules apply to the three CLI OAuth endpoints and to the CLI's use of the shared token and revocation endpoints:
- Send an
application/x-www-form-urlencodedbody, with each field once and no URL query string. Anything else returns400 invalid_request. The device authorization and consent review endpoints also reject fields they don't define. - Send
client_id=flint-cli, with noclient_secretand noAuthorizationheader. Otherwise the request fails with401 invalid_client. - Responses are flat OAuth JSON without the Flint envelope, sent with
Cache-Control: no-store. Flint never replays these requests from an idempotency cache.
Sign in#
- CLI sends device/authorize to Flint
- Flint returns device_code, user_code to CLI
- CLI sends opens the link to Browser
- Browser sends approves to Flint
- CLI sends polls /oauth/token to Flint
- Flint returns authorization_pending to CLI
- Flint returns tokens, context_id to CLI
- CLI sends API calls to Flint
Start the login#
curl https://api.withflintpay.com/v1/oauth/device/authorize \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=flint-cli" \
--data-urlencode "session_mode=contexts" \
--data-urlencode "mode=test" \
--data-urlencode "scope=payments.payment_intents.read payments.payment_intents.write"
| Field | Required | Value |
|---|---|---|
client_id | Yes | flint-cli. |
scope | Yes | Space-separated API scope names. The person can grant each context any of them, and nothing beyond them. scopes_supported in OAuth discovery lists every name the CLI can request; an unknown name returns invalid_scope. |
session_mode | No | Defaults to contexts when omitted. Any other value returns 400 invalid_request naming session_mode. This creates a session that can hold several contexts and supports context listing and consent review. |
mode | No | test (the default) or live. The approval must include a context in this mode, and the first access token is for it. The person can still approve contexts in the other mode. |
merchant_id | No | Limits every approved context to this merchant. |
sandbox_id | No | Limits every approved context to this sandbox, so no live context can be approved. Don't combine it with mode=live. |
{
"device_code": "fcdc_example",
"user_code": "WDJB-MJHT",
"verification_uri": "https://app.withflintpay.com/cli/activate",
"verification_uri_complete": "https://app.withflintpay.com/cli/activate?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}
Open verification_uri_complete in the person's browser, or show verification_uri and user_code for them to open and type. The link only identifies the request; nothing is approved until the person approves it.
What the person approves#
The person signs in to the dashboard, checks that the code matches, and chooses contexts. Flint starts them on a likely context, and they can add other sandboxes, other merchants, or live access, or remove scopes, before they approve or deny. Live contexts carry a live label.
Each approved context needs at least one scope, and only scopes from your request. A scope that a mode doesn't allow, such as a live-only scope in a sandbox, can't be granted in that mode. A session can track up to 100 contexts, including ones later removed.
Poll for tokens#
Poll the token endpoint with the device code, waiting interval seconds between requests:
curl https://api.withflintpay.com/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
--data-urlencode "client_id=flint-cli" \
--data-urlencode "device_code=$DEVICE_CODE"
Until the login finishes, each poll returns 400 with one of these error values:
error | Meaning | Next step |
|---|---|---|
authorization_pending | The person hasn't decided yet. | Wait interval seconds and poll again. |
slow_down | You polled before the interval passed, or polled too often. | Add five seconds to your interval, keep the longer interval for later polls, and poll again. |
access_denied | The person denied the request. | Stop. |
expired_token | The code expired after expires_in seconds, or was already redeemed. | Start a new login. |
Once the person approves, the next poll returns the session's first tokens:
{
"access_token": "fcat_example",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "fcrt_example",
"scope": "payments.payment_intents.read payments.payment_intents.write",
"oauth_session_id": "session_example",
"context_id": "ctx_example"
}
scope lists what the person granted to this context, which can be fewer scopes than you asked for. Store the refresh token as a secret; the Flint CLI keeps it in the OS keychain.
Call the API#
Send the access token in the Authorization header. It acts only in its context, so you don't need Flint-Mode or Flint-Sandbox-Id. GET /v1/auth-context shows which context a token belongs to:
curl https://api.withflintpay.com/v1/auth-context \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"data": {
"auth_type": "oauth",
"oauth_session_id": "session_example",
"context_id": "ctx_example",
"oauth_grant_id": "clg_01KEZF7C00Q6S9W2D8HF4YBN3T",
"name": "Example store",
"merchant_id": "mer_01KEZF7C00MPP0T9S0KH5BB4CC",
"environment": "sandbox",
"sandbox_id": "test_01KEZF7C00JVWYYN2SBWFFSA36",
"scopes": [
"payments.payment_intents.read",
"payments.payment_intents.write"
]
},
"request_id": "req_01KWM64QRVW8VG2M0B0BAHH3Q3"
}
oauth_grant_id identifies the context's grant and stays the same across refreshes and consent changes. API requests made with a CLI token return the usual Flint error envelope:
- StatusHTTP 401What to do
details[0].reasonunknown: the access token expired, its session ended, or consent removed or changed its context. Refresh for that context, and sign in again if the refresh returnsinvalid_grant.malformedwithparamX-API-Key: the request also sentX-API-Key. Send credentials only inAuthorization. - StatusHTTP 403CodeFORBIDDENWhat to do
details[0].reasonoauth_context_mismatch: aFlint-Merchant-Id,Flint-Mode, orFlint-Sandbox-Idheader names a different merchant, mode, or sandbox than the token's context, andparamnames the header. Remove the header, or refresh for the context you meant. - StatusHTTP 403What to doThe context doesn't hold a scope the operation needs.
details[0].missing_scopeslists them. Add them to the context. - StatusHTTP 503What to doFlint couldn't check the token. Retry the request.
Refresh and switch contexts#
Refresh to get a new access token for any context in the session. Naming a different context_id is how you switch contexts.
curl https://api.withflintpay.com/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "client_id=flint-cli" \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode "context_id=ctx_example"
The response has the same fields as the login response, with a new access_token and refresh_token.
| Field | Required | Value |
|---|---|---|
context_id | Yes | The context the new access token is for. |
scope | No | A space-separated subset of the context's scopes, to narrow this one access token. The context keeps its full set, and the next refresh without scope returns all of it. A scope the context doesn't hold returns invalid_scope. |
Warning: Save every replacement refresh token
Each successful refresh replaces the session's refresh token. Store the new one before you make another request. Sending a refresh token that was already replaced ends the whole session with invalid_grant, including when you never received the response that replaced it. Don't retry a refresh automatically; if one fails without a response, sign in again.
- Access tokens issued earlier keep working until they expire, in every context. A refresh doesn't revoke them.
invalid_contextmeanscontext_idis missing or isn't part of this session.context_access_deniedmeans consent removed the context, or the person lost access to it. Neither error uses up the refresh token: list the contexts and refresh for one of them.invalid_grantmeans the session has ended. Start a new login.- Each refresh restarts the 30-day idle period. Near the 90-day limit,
expires_inis shorter than 900 seconds.
List the contexts#
curl https://api.withflintpay.com/v1/oauth/contexts \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=flint-cli" \
--data-urlencode "refresh_token=$REFRESH_TOKEN"
{
"oauth_session_id": "session_example",
"contexts": [
{
"id": "ctx_example",
"name": "Example store",
"merchant_id": "mer_01KEZF7C00MPP0T9S0KH5BB4CC",
"environment": "sandbox",
"sandbox_id": "test_01KEZF7C00JVWYYN2SBWFFSA36"
},
{
"id": "ctx_example_live",
"name": "Example store",
"merchant_id": "mer_01KEZF7C00MPP0T9S0KH5BB4CC",
"environment": "live"
}
]
}
Send the current refresh token. Listing checks it without replacing it, so it works while the access token is expired. It grants no API access and doesn't return scopes; read those from a token response's scope or from GET /v1/auth-context.
environment is sandbox for a test-mode context, which also has sandbox_id, and live for a live one. The list holds only contexts the person can still use, in the order they were last approved, with no pagination. An empty list means no context is usable; review consent to add one.
Change what a session can access#
POST /v1/oauth/device/reauthorize starts a new browser approval for an existing session. Send the current refresh token and session_mode=contexts. The response, the browser step, and polling work as they do at login. Starting a review changes nothing, and a denied or expired review leaves the session as it was. Redeeming the device code for a superseded review returns 400 expired_token; start a new review. The existing session remains active.
There are two kinds of review:
| Full review | Add scopes to one context | |
|---|---|---|
scope_mode | Omit | additive |
context_id | Optional. The context the new access token should be for. | Required. The context that gains scopes. |
scope | Every scope any context may hold afterward. The person can't approve a scope you didn't request. | Only the scopes to add. A scope the context's mode doesn't allow returns invalid_scope. |
| In the browser | The person sees every current context, and can add or remove contexts and reduce scopes. | The person sees that context and approves some or all of the added scopes. |
| After approval | The approved contexts replace the old set. Access tokens for removed contexts, and for contexts whose scopes changed, stop working. | The approved scopes join the context's existing scopes. If its scopes change, its earlier access tokens stop working. Other contexts are unchanged. |
To add scopes to one context:
curl https://api.withflintpay.com/v1/oauth/device/reauthorize \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=flint-cli" \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode "session_mode=contexts" \
--data-urlencode "scope_mode=additive" \
--data-urlencode "context_id=ctx_example" \
--data-urlencode "scope=commerce.subscriptions.read"
merchant_id and sandbox_id limit a full review's contexts as they do at login. An additive review takes its merchant and mode from the context, so a merchant_id, sandbox_id, or mode that disagrees returns invalid_context, and a context the session no longer holds returns context_access_denied.
Redeem the review's device code at the token endpoint as you would at login. Redemption:
- Keeps
oauth_session_idand replaces the session's refresh token. Discard the refresh token you started the review with: sending it afterward ends the session. - Returns an access token for
context_idwhile that context remains. Otherwise the token is for a sandbox context, so a full review must keepcontext_id's context or at least one sandbox context. - Reports in
scopewhat the person granted, which can be a subset of what you asked for.
Approving a review cancels every other review of the same session that hasn't been redeemed yet. Start a new review if you still need one.
Sign out#
Revoke any access or refresh token from the session, including a refresh token that was already replaced:
curl https://api.withflintpay.com/v1/oauth/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=flint-cli" \
--data-urlencode "token=$REFRESH_TOKEN" \
--data-urlencode "token_type_hint=refresh_token"
A 200 with an empty body means the session has ended, whether this request ended it or it had already ended. An unknown token also returns 200. Every token in every context stops working, and webhook event streams opened with them close. People can also review and end their sessions on the dashboard's CLI sessions page.
OAuth errors#
Every call above returns failures as error with a fixed error_description. Branch on error; the description is for people.
| Status | error | Cause and next step |
|---|---|---|
400 | invalid_request | The body isn't a valid form, repeats a field, has a query string, or sends a field the endpoint doesn't define, or a required field is missing or invalid. Fix the request. |
401 | invalid_client | client_id isn't flint-cli, or the request sent a client secret or an Authorization header. |
400 | invalid_grant | The refresh token is spent, revoked, or expired, or the session has ended. Start a new login. |
400 | invalid_scope | A scope is unknown, missing, not allowed in the context's mode, or outside the context's scopes on refresh. |
400 | invalid_context | context_id is missing or isn't in this session, or an additive review's hints disagree with its context. |
400 | context_access_denied | The context was removed, or the person lost access to it. Choose another context. |
400 | unsupported_grant_type | Use urn:ietf:params:oauth:grant-type:device_code or refresh_token. |
400 | authorization_pending, slow_down, access_denied, expired_token | Device code polling only. See Poll for tokens. |
429 | temporarily_unavailable | A rate limit outside device code polling. Wait at least Retry-After seconds; Flint-Rate-Limited-Reason names the limit. |
500 | server_error | Retry later, and quote X-Request-Id to Flint support. |
Related#
Flint CLISign in, switch contexts, add permissions, and sign out from the terminal.OAuthThe shared token and revocation endpoints and their client rules.OAuth discovery and signing keysEndpoints, grants, and the scopes the CLI can request.AuthenticationAPI keys, scopes, and the other credentials Flint accepts.List authorized CLI contexts#
/v1/oauth/contextsNo API key required
Rate limit: per IP
First-party public client flint-cli. Uses application/x-www-form-urlencoded and flat OAuth JSON without a Flint envelope. No client secret or HTTP Basic authentication. Requests are not replayed through idempotency caching. Flint session control extension. Authenticates the current refresh token without consuming or rotating it. Returns only currently authorized contexts, including an empty list. Listing does not change consent, revoke contexts, or update session state. Read approved scopes from the selected context's token response or /v1/auth-context. No business access is granted. Default order: the order of the contexts approved at the latest consent.
Response · 200
OAuth response. Credential responses include Cache-Control: no-store and Pragma: no-cache.
contextsarray of objectRequiredenvironmentenumRequiredWhether the context is a sandbox or a live account.
sandboxlive
idstringRequiredID of the context. Name it as `context_id` when you refresh a token.
merchant_idstringRequiredID of the merchant this resource belongs to.
namestringRequiredName of the context.
sandbox_idstringID of the sandbox this resource belongs to.
oauth_session_idstringRequiredcurl -X POST https://api.withflintpay.com/v1/oauth/contexts \
-H "Flint-Version: 2026-09-07"Start CLI browser login#
/v1/oauth/device/authorizeNo API key required
Rate limit: per IP
First-party public client flint-cli. Uses application/x-www-form-urlencoded and flat OAuth JSON without a Flint envelope. No client secret or HTTP Basic authentication. Requests are not replayed through idempotency caching. Send session_mode=contexts for a session that supports additive reauthorization. The required scope field contains the full requested set as space-separated names. Normal login sends the unchanged x-flint-cli-bootstrap-scopes; login with extra permissions sends the desired baseline and additions. No scopes are implicitly added. The browser may approve a subset per context; token scope reports the approved set. Unknown scopes return HTTP 400 invalid_scope. Selected scopes are checked against the selected merchant and environment before approval.
Response · 200
OAuth response. Credential responses include Cache-Control: no-store and Pragma: no-cache.
device_codestringRequiredexpires_inintegerRequiredintervalintegerRequireduser_codestringRequiredverification_uristringRequiredverification_uri_completestringRequiredcurl -X POST https://api.withflintpay.com/v1/oauth/device/authorize \
-H "Flint-Version: 2026-09-07"Review CLI session consent#
/v1/oauth/device/reauthorizeNo API key required
Rate limit: per IP
First-party public client flint-cli. Uses application/x-www-form-urlencoded and flat OAuth JSON without a Flint envelope. No client secret or HTTP Basic authentication. Requests are not replayed through idempotency caching. Flint session control extension. Starts device consent bound to the current session and user without consuming the refresh token. Without scope_mode, approval replaces consent; denial or expiry leaves consent unchanged. Redeem using the device_code grant to rotate the latest family token. Set scope_mode=additive and context_id to request additional space-separated scope names for one existing context. Merchant and environment are derived from that context; conflicting hints return invalid_context. Approval unions only browser-approved additions with that context's existing scopes and preserves all other contexts. No implicit bootstrap scopes are added. Unsupported or environment-disallowed scopes return HTTP 400 invalid_scope; browser denial returns access_denied when polling. Partial approval succeeds and scope in the token response is authoritative. A superseded consent review returns expired_token when its device code is redeemed; start a new review. The existing session remains active.
Response · 200
OAuth response. Credential responses include Cache-Control: no-store and Pragma: no-cache.
Same response as Start CLI browser login.
curl -X POST https://api.withflintpay.com/v1/oauth/device/reauthorize \
-H "Flint-Version: 2026-09-07"Was this helpful?
