Sample app
The API quickstart walks through the OAuth flow with curl.
This page points you at a runnable web app that does the same thing in real
code — register a client, send a user through consent, exchange the code for
tokens, keep the connection alive with refresh-token rotation, and create a
draft invoice.
Two complete, dependency-light samples are available as downloadable starter projects — grab the one for your stack, unzip, and run:
What the sample does
Section titled “What the sample does”- Builds the authorize URL with a random
stateand a PKCEcode_challenge. - Verifies
stateon the callback (CSRF defense) and exchanges the code for tokens. - Stores tokens server-side and refreshes them before they expire.
- Serializes refreshes per session so two concurrent requests never rotate the same refresh token twice (AccountFUN invalidates that token’s family — the rotation chain from one authorization — on reuse).
- Calls the resource API to read the organization, list customers, list income accounts, and create a draft invoice.
- Handles a dead grant (expired refresh token, revoked family) by re-prompting authorization.
Prerequisites
Section titled “Prerequisites”- Have an AccountFUN organization with at least one active customer and
one active
INCOMEaccount. The sample creates an invoice against them. - Register a client under Settings → API & Developers → New App with
the scopes
org:read,contacts:read,invoices:read,invoices:write,chart_of_accounts:read, andoffline_access. See Authentication for the details. - Add the sample’s callback as a redirect URI on that client —
http://localhost:3000/callbackfor Node,http://localhost:5000/callbackfor Python.http://localhostis allowed for development.
Run it
Section titled “Run it”Pick a language, install dependencies, and fill in your client credentials. The app prints the exact callback URL you need to have registered.
cd node-oauth-demonpm installcp .env.example .env # then add your CLIENT_ID / CLIENT_SECRETnpm start # → http://localhost:3000Requires Node.js 18.17 or newer (uses the global fetch).
cd python-oauth-demopython -m venv .venv && source .venv/bin/activatepip install -r requirements.txtcp .env.example .env # then add your CLIENT_ID / CLIENT_SECRETpython app.py # → http://localhost:5000Requires Python 3.10 or newer.
Open the printed URL, click Connect to AccountFUN, approve the consent
screen, and you’ll land on a small dashboard. Pick a customer and an income
account and click Create draft invoice — a new DRAFT invoice appears in
AccountFUN.
How it works
Section titled “How it works”The four snippets below are the heart of each sample. They’re excerpted from the downloadable projects above, which also handle the HTML UI, the logout/revoke path, and error rendering.
1. Send the user to authorize
Section titled “1. Send the user to authorize”Generate a random state (CSRF defense) and a PKCE verifier/challenge, then
redirect to the authorize endpoint.
const { verifier, challenge } = makePkce();const state = crypto.randomBytes(16).toString("hex");req.session.oauth = { verifier, state };
const params = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: SCOPES, state, code_challenge: challenge, code_challenge_method: "S256",});res.redirect(`${ACCOUNTFUN_API_URL}/api/v1/oauth/authorize?${params}`);verifier, challenge = make_pkce()state = secrets.token_urlsafe(16)session["oauth_verifier"] = verifiersession["oauth_state"] = state
params = { "response_type": "code", "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "scope": SCOPES, "state": state, "code_challenge": challenge, "code_challenge_method": "S256",}url = requests.Request( "GET", f"{ACCOUNTFUN_API_URL}/api/v1/oauth/authorize", params=params).prepare().urlreturn redirect(url)2. Exchange the code for tokens
Section titled “2. Exchange the code for tokens”On the callback, verify state, then POST the code (plus the PKCE
verifier) to the token endpoint.
// Never exchange a code without confirming state came back unchanged.if (state !== oauth.state) return res.status(400).send("Invalid state");
const tok = await postToken({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: oauth.verifier, // ties back to the PKCE challenge});saveTokens(req.session, tok);# Never exchange a code without confirming state came back unchanged.if state != session.pop("oauth_state", None): abort(400)
tok = post_token({ "grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code_verifier": session.pop("oauth_verifier", None),})_save_tokens(tok)3. Keep the connection alive
Section titled “3. Keep the connection alive”Access tokens expire after 30 minutes. With offline_access you also received a
rotating refresh token. The old refresh token dies the moment you use it —
store the new one immediately, and never let two requests rotate the same token
at once.
// One in-flight refresh per session — a second concurrent rotation would// invalidate the entire token family.const tok = await postToken({ grant_type: "refresh_token", refresh_token: session.tokens.refresh_token, client_id: CLIENT_ID, client_secret: CLIENT_SECRET,});saveTokens(session, tok); // replaces the now-dead refresh token# One in-flight refresh per session — a second concurrent rotation would# invalidate the entire token family.tok = post_token({ "grant_type": "refresh_token", "refresh_token": TOKEN_STORE[sid]["refresh_token"], "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,})_save_tokens(tok) # replaces the now-dead refresh tokenBoth samples refresh proactively before the access token expires, and again once
on a 401 invalid_token to recover from a revoked token. If the refresh itself
fails with invalid_grant, the grant is dead and the user is sent back to
authorize.
4. Create a draft invoice
Section titled “4. Create a draft invoice”Once you have a valid access token, call the resource API. Send money and
quantities as strings, and reference a customer contactId and an INCOME
accountId. The samples ask you to choose issue and due dates as calendar days;
they send those YYYY-MM-DD values directly, without converting through a time
or timezone.
// Read date-only strings from <input type="date">; do not convert to a time.const res = await apiRequest(req, "POST", "/api/v1/invoices", { body: { contactId, issueDate, // "YYYY-MM-DD" dueDate, // "YYYY-MM-DD" lines: [ { description, quantity: "1", unitAmount: "50.00", accountId }, ], },});const { invoice } = await res.json(); // 201 Created# Date input values are already calendar dates in "YYYY-MM-DD" form.r = api_request("POST", "/api/v1/invoices", json_body={ "contactId": contact_id, "issueDate": issue_date, "dueDate": due_date, "lines": [ {"description": description, "quantity": "1", "unitAmount": "50.00", "accountId": account_id}, ],})invoice = r.json()["invoice"] # 201 CreatedThe invoice is created in DRAFT. Approve it inside AccountFUN to post it to
your books.
Production notes
Section titled “Production notes”The samples are intentionally minimal. Before shipping a real integration:
- Store tokens encrypted at rest in a durable store (database, secret manager) — not in the in-memory session these demos use.
- Run behind HTTPS and use HTTPS redirect URIs (
http://localhostis development-only). - Rotate the
client_secretif it leaks, and revoke tokens via the revoke endpoint when a user disconnects. - Handle
429withRetry-Afterand apply exponential backoff on5xx.