Skip to content
AccountFUNDocs

Authentication

AccountFUN’s public API uses the OAuth 2.0 Authorization Code grant (RFC 6749 §4.1, hardened per RFC 9700). This is the standard three-legged flow used by server-side applications that act on behalf of a user.

The flow has three legs:

  1. Authorize — redirect the user to AccountFUN, where they sign in and approve your app.
  2. Token exchange — trade the short-lived authorization code for an access token (and optional refresh token) at your server.
  3. Resource calls — send the access token with every API request.
EndpointMethodPurpose
/api/v1/oauth/authorizeGET, POSTAuthorization-code grant entry. GET renders the consent screen; POST is the Allow/Deny action.
/api/v1/oauth/tokenPOSTExchange a code for tokens, or rotate a refresh token.
/api/v1/oauth/revokePOSTRevoke an access or refresh token (RFC 7009).

All token-endpoint requests must use Content-Type: application/x-www-form-urlencoded.

Clients that discover endpoints automatically can read the authorization server metadata (RFC 8414) at /.well-known/oauth-authorization-server. This is how AI assistants find them. If an authorization request leaves out scope, the client’s registered scopes are requested.

Direct the user’s browser to the authorize endpoint with your client_id, the redirect_uri you registered, the scope you need, and a random state value:

GET https://app.accountfun.com/api/v1/oauth/authorize \
?response_type=code \
&client_id=YOUR_CLIENT_ID \
&redirect_uri=https://your-app.example.com/callback \
&scope=invoices:read%20invoices:write%20offline_access \
&state=RANDOM_UNGUESSABLE_STRING
ParameterRequiredDescription
response_typeYesMust be code.
client_idYesYour client identifier.
redirect_uriYesMust exactly match a URI registered on your client. HTTPS is required in production (http://localhost is allowed for development).
scopeNoSpace-delimited list of scopes. Scopes your client isn’t registered for are ignored; if none of the requested scopes is registered, the request fails. Omit it to request all of your client’s registered scopes.
stateYesAn unguessable value you generate. It is echoed back in the redirect and must be verified to prevent CSRF.
code_challengeYesPKCE challenge — base64url(SHA-256(code_verifier)), 43 characters (see PKCE).
code_challenge_methodYesMust be S256. plain is not accepted.

The user signs in to AccountFUN (if they aren’t already), selects which of their organizations to connect, and reviews the requested scopes on a consent screen. They click Allow or Deny.

  • Approving is opt-in. bills:approve and invoices:approve are listed under Only if you tick it: as unticked checkboxes. If the user doesn’t tick one, it’s left out of the grant.
  • The user’s role sets the limit. The list follows the selected organization: access the user’s role there can’t grant is shown as “(not available with your role in this organization)” and left out of the grant, and the connection works with the rest. If the role can grant none of the requested access, the connection is refused.
  • Only the creator’s organizations. The picker offers only organizations that the person who registered the app (under Settings → API & Developers) belongs to. Members of other organizations see ”… was set up in another organization, so it can’t be connected here” and should ask their own admin to register the app.
  • The screen tells the user how to take the access back later: they can disconnect the app under Settings → Account → Connected Apps, and an admin of the organization can revoke it under Settings → Authorized Applications.

The scope in the token response lists exactly what was granted, which can be less than you asked for.

If something is wrong with the request — response_type isn’t code, state or a valid PKCE code_challenge is missing, the redirect_uri doesn’t match a registered URI, or none of the requested scopes is registered on your client — AccountFUN shows an error page and does not redirect back to your app. The browser is sent to your redirect_uri only after the user clicks Allow or Deny, and never to an unregistered URI.

Requested scopes that your client isn’t registered for are ignored, not treated as an error.

Clicking Deny is the only case that redirects back to you with an error. You receive:

GET https://your-app.example.com/callback?error=access_denied&state=RANDOM_UNGUESSABLE_STRING

Handle this gracefully — show the user a “try again later” message.

On Allow, AccountFUN redirects to your redirect_uri with a single-use code:

GET https://your-app.example.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_UNGUESSABLE_STRING

Before exchanging the code, verify that state matches what you sent in step 1. If it doesn’t, discard the request.

The authorization code is:

  • Single-use — it can be exchanged exactly once.
  • Short-lived — it expires after 60 seconds.
  • Bound to your client_id and redirect_uri — both must match at exchange time.

Exchange it immediately. Do not store it.

From your server, POST to the token endpoint:

Terminal window
curl -X POST https://app.accountfun.com/api/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=AUTHORIZATION_CODE \
-d redirect_uri=https://your-app.example.com/callback \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET

You may authenticate the client with HTTP Basic instead of the form body (RFC 6749 §2.3.1), but you must not mix both methods in one request:

Authorization: Basic base64(YOUR_CLIENT_ID:YOUR_CLIENT_SECRET)
{
"access_token": "ab12cd34...64-hex-chars",
"token_type": "Bearer",
"expires_in": 1800,
"scope": "invoices:read invoices:write offline_access",
"organization_id": "00000000-0000-0000-0000-000000000000",
"refresh_token": "ef34ab12...64-hex-chars"
}
FieldDescription
access_tokenThe opaque bearer token. Send it in the Authorization header on every API call.
token_typeAlways Bearer.
expires_inSeconds until the access token expires (1800 = 30 minutes).
scopeThe scopes granted to this token (space-delimited). This is exactly what the user granted, which can be less than you requested: an approve scope they didn’t tick, or access their role can’t grant. Check it before using a feature that needs a scope.
organization_idThe organization the user selected at consent. All API calls are scoped here.
refresh_tokenPresent only if offline_access was granted. Use it to mint new token pairs without re-prompting the user.

Once you have an access token, call any resource endpoint:

Terminal window
curl https://app.accountfun.com/api/v1/invoices \
-H "Authorization: Bearer ab12cd34...64-hex-chars"

Every request is automatically scoped to the organization_id the token was issued for. You cannot read or write data in any other organization with that token — the boundary is enforced server-side on every query.

Access tokens expire after 30 minutes. When the offline_access scope was granted, you also received a refresh token. Use it to mint a new access + refresh pair before the old access token expires:

Terminal window
curl -X POST https://app.accountfun.com/api/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=CURRENT_REFRESH_TOKEN \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET

The response has the same shape as the authorization-code response. The new refresh token replaces the old one — keep only the latest.

AccountFUN rotates the refresh token on every use:

  • The presented refresh token is revoked immediately.
  • A new refresh token is returned, chained to the old one.
  • Each successful rotation resets the 30-day inactivity window. There is no absolute lifetime cap; a refresh token expires after 30 days without use.

If an already-rotated refresh token is presented again, AccountFUN treats this as a theft signal and revokes that token’s family — the rotation chain it belongs to and every access token minted along it. The next call with any refresh token from that chain returns invalid_grant; resource requests using an access token from it return 401 invalid_token.

A family is one authorization: the tokens issued when a code was exchanged, plus everything rotated from them. If the same user has authorized your app more than once (for example from two devices), each authorization is its own family, and a reuse in one leaves the others working. The consent itself (the grant) is not revoked either — the user does not have to consent again, your app only has to run the authorization flow again to get a new family.

This means: always discard the old refresh token the moment you’ve used it. If two parts of your system race and both try to rotate the same token, one will succeed and the other will trigger family invalidation. To avoid this, serialize refreshes per-session in your app.

PropertyValue
LifetimeRolling 30 days of inactivity; no absolute cap
RotationOn every use (old token revoked, new token issued)

PKCE (Proof Key for Code Exchange, RFC 7636) binds the authorization code to the client instance that started the flow, so a leaked or injected code is useless to anyone else. AccountFUN requires PKCE with the S256 method on every authorization request, in line with the OAuth 2.0 Security Best Current Practice (RFC 9700). This applies even though AccountFUN clients are confidential (they authenticate with a secret).

  1. Generate a high-entropy code_verifier: 43–128 characters from A–Z a–z 0–9 - . _ ~.
  2. Derive the code_challenge: base64url(SHA-256(code_verifier)) with no padding (always 43 characters).
  3. Send code_challenge + code_challenge_method=S256 in the authorize request.
  4. Send the original code_verifier in the token exchange.
# Authorize
&code_challenge=BASE64URL_SHA256_OF_VERIFIER
&code_challenge_method=S256
# Token exchange
&code_verifier=ORIGINAL_PLAIN_VERIFIER

An authorize request without a valid S256 challenge is rejected with an AccountFUN error page; the user isn’t sent back to your app. The token endpoint verifies the code_verifier against the stored challenge; a missing or mismatched verifier returns invalid_grant.

To revoke an access or refresh token (e.g. when a user disconnects your app), call the revocation endpoint (RFC 7009):

Terminal window
curl -X POST https://app.accountfun.com/api/v1/oauth/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=TOKEN_TO_REVOKE \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET
ParameterRequiredDescription
tokenNoThe access or refresh token to revoke.
token_type_hintNoaccess_token or refresh_token (a hint only — both tables are checked).
client_idYesYour client identifier.
client_secretYesYour client secret.

The endpoint always returns 200 OK, even if the token was unknown, already revoked, or belonged to a different client — this prevents leaking token validity. A 401 with { "error": "invalid_client" } is returned only when your own client credentials are wrong, and a 429 (with Retry-After) when you exceed the rate limits — the same bodies the token endpoint sends.

You may revoke only tokens that belong to your client.

Inside AccountFUN, a user can disconnect your app at any time under Settings → Account → Connected Apps, and an admin of the organization can revoke any member’s connection under Settings → Authorized Applications; the consent screen tells users both. Either one immediately invalidates the consent grant and all derived access and refresh tokens — the next API call returns 401 invalid_token.

AccountFUN also revokes every connection a user has made when their account may have been at risk: a password reset or change, two-factor authentication turned off with a recovery code or reset by AccountFUN support, and “sign out other sessions”. Your refresh then fails with invalid_grant: send the user through authorization again.

A token’s authority is the intersection of two things:

  1. The scopes the token carries (what the user consented to).
  2. The user’s live role in the organization (re-checked on every request).

A token minted when the user was an admin becomes inert the instant the user is demoted or removed from the org — the token’s scope string hasn’t changed, but the live role no longer permits the action. This is enforced on every single API request; there is no staleness window.

Every resource scope maps to a permission the authorizing user’s role must actually hold — whatever the role is called. With custom roles, that means the specifics of the role, not its name, decide what a token can do: a custom role with the right permissions can use the API even though it isn’t a seeded role, and a role without them cannot use the API at all.

The same check runs at consent: access the user’s role can’t grant in the selected organization is left out of the grant. bills:approve and invoices:approve need the Approve Documents permission, and each approves only its own document type — invoices:approve can’t approve a bill.

The token and revoke endpoints accept client credentials in two ways:

MethodHow
Form bodyclient_id + client_secret in the URL-encoded body.
HTTP BasicAuthorization: Basic base64(client_id:client_secret).

Pick one per request. Mixing both is rejected.

When building your integration:

  • Store the client_secret on your server only. Never ship it in a mobile app, single-page app, or any code that runs in the user’s browser.
  • Verify state on every callback. This is your CSRF defense.
  • Read the granted scope in the token response. Don’t assume the user granted everything you asked for — approving, for one, is opt-in.
  • Store tokens encrypted at rest. Rotate your storage key periodically.
  • Discard old refresh tokens immediately after rotation. Never keep a stash — reuse triggers family invalidation.
  • Use HTTPS everywhere. Redirect URIs must be HTTPS in production.
  • Handle 401 invalid_token gracefully by re-prompting authorization.
  • Never put tokens in URLs (query strings, fragments). Use the Authorization header.