Connecting an app with OAuth
The authorization-code flow a third-party app uses to receive a scoped Foundry access token — endpoints, parameters, errors, and what the token actually is.
This is how a third-party app receives its access token. The merchant approves a consent screen and your server receives the credential directly — nobody copies a key out of the Foundry admin and pastes it into your app.
If you’re deciding whether to build an app, start with building a partner app. If you’re ready to ship one, publishing an app covers submission and review.
Before you start
You need two things from us, and both are granted rather than requested:
- Developer access, which also provisions your sandbox. See publishing an app.
- A
client_idandclient_secret. Talk to us once your app exists. The secret is shown once and is not recoverable — if you lose it, we rotate it, which invalidates the old one immediately.
You also register your redirect URIs, which live on your app version. Changing them is a version bump that goes through review, exactly like changing a scope. That’s deliberate: the redirect URI is the one thing standing between an authorization flow and an open redirect.
The flow
1. You → send the merchant to GET /api/v1/oauth/authorize
2. We → show them a consent screen listing exactly what you asked for
3. We → redirect back to your URI with ?code=…&state=…
4. You → POST /api/v1/oauth/token (server-to-server, with your secret)
5. We → return a scoped access token
Steps 1–3 happen in the merchant’s browser. Steps 4–5 are server-to-server and your client_secret must never leave your backend.
1. Send the merchant to authorize
GET https://api.foundryims.com/api/v1/oauth/authorize
?client_id=fapp_…
&redirect_uri=https://yourapp.example.com/oauth/callback
&response_type=code
&scope=products.read%20orders.read
&state=<random, unguessable, tied to this user's session>
| Parameter | |
|---|---|
client_id | Required. Issued to you. |
redirect_uri | Required. Must exactly match one you registered. |
response_type | Required, and must be code. |
scope | Optional, space-delimited. Omit it and you get everything your approved version declared. You cannot request more than that. |
state | Required. See below. |
state is required
The OAuth spec permits omitting it. We don’t. Without state the flow is open to CSRF — an attacker can trick a merchant into authorizing your app into an org of the attacker’s choosing. Generate an unguessable value, tie it to the browser session that started the flow, and reject the callback if it doesn’t match.
Redirect URIs match byte-for-byte
No prefix matching, no wildcards, no trailing-slash tolerance, no case-insensitivity. https://app.example.com/cb and https://app.example.com/cb/ are different URIs. Register the exact string you redirect to.
This is stricter than the RFC requires. Every normalisation step is a place to get a comparison subtly wrong, and the cost to you is registering an exact string once.
2. The merchant consents
They see your app’s name, who built it, your description, which of their workspaces they’re connecting, and a plain-language list of every permission you asked for. They choose the workspace here — so an agency managing several Foundry organizations connects the right one.
They can also decline, in which case you get error=access_denied (below) rather than silence.
3. You receive a code
https://yourapp.example.com/oauth/callback?code=…&state=…
Verify state matches what you sent before doing anything else.
The code is single-use and expires in 10 minutes. Exchange it immediately.
4. Exchange the code for a token
curl -X POST https://api.foundryims.com/api/v1/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"code": "…",
"client_id": "fapp_…",
"client_secret": "fsec_…",
"redirect_uri": "https://yourapp.example.com/oauth/callback"
}'
redirect_uri must be the same one you used in step 1. It is checked, not decorative.
{
"access_token": "fims_…",
"token_type": "Bearer",
"scope": "products.read orders.read",
"org_id": "0f5c…"
}
Store the token against org_id. Installations are keyed by organization, and a merchant re-authorizing must overwrite the previous record rather than create a second one — re-authorization revokes the old token immediately. You can also read it any time from GET /orgs/me.
The request body is JSON, not form-encoded. Most OAuth providers accept application/x-www-form-urlencoded at the token endpoint; this one wants JSON, as shown.
5. Use the token
curl https://api.foundryims.com/api/v1/products \
-H "Authorization: Bearer fims_…"
The token is an ordinary Foundry API key, scoped to exactly what the merchant approved. There is no separate token format and nothing to exchange it for. Practical consequences:
- It does not expire, and there are no refresh tokens. Nothing silently dies after 30 days.
- It is revocable. The merchant can revoke it from Settings → Apps, and uninstalling revokes it. Handle a
401by asking them to reconnect. - It carries only the approved scopes. A call outside them returns
403, not401. Don’t treat those the same —403means you asked for something you were never granted, and retrying won’t help. - It counts against the standard rate limit of 300 requests/minute.
Re-authorization
A merchant can run the flow again for an org that already has your app installed — after you add a scope, or if they need to reconnect.
Re-authorizing replaces your credential. The previous token is revoked immediately. If you run your app in more than one place against the same organization, the others stop working until they pick up the new token. Store tokens per organization and update on every successful exchange rather than only on first install.
Webhooks are not provisioned for you
An app installed through OAuth gets an access token and nothing else. If you want webhook deliveries, register your own endpoint with the token you receive — see webhook events.
This differs from a manual install, where the merchant supplies a webhook URL in the install form. In an OAuth flow there is nobody to ask: the endpoint belongs to you, not to them.
Errors
Errors that happen before we trust your redirect_uri are rendered in place as JSON, because sending a browser to an unvalidated URI is how open redirects are built:
{ "error": "invalid_client", "error_description": "Unknown client" }
Everything after that redirects back to you with the error in the query string, carrying your state:
https://yourapp.example.com/oauth/callback?error=invalid_scope&state=…&error_description=…
| Code | What it means |
|---|---|
invalid_request | A required parameter is missing or redirect_uri doesn’t match a registered one. |
invalid_client | Unknown client_id, or client authentication failed at the token endpoint. |
invalid_grant | The code is unknown, expired, already used, issued to a different client, or paired with a different redirect_uri. |
invalid_scope | You asked for a scope your approved version doesn’t declare. |
unsupported_response_type | response_type was not code. |
unsupported_grant_type | grant_type was not authorization_code. |
access_denied | The merchant declined. |
A 403 is not an auth failure
Worth knowing here rather than discovering it in production: existing installations keep the version they consented to. When you publish a version requesting a new scope, orgs that installed the old one keep the old scopes until they re-authorize.
So a call can return 403 on some installations and 200 on others with the same code deployed. Don’t treat that as a broken token — record the scopes granted at install (the scope field above) and degrade rather than assuming a permission you were granted somewhere is a permission you have everywhere. GET /developer/apps/:appId/installs reports how your installations are distributed across versions.
Failures are deliberately indistinguishable. “No such client” and “wrong secret” both return invalid_client; “expired code” and “already used” both return invalid_grant. That’s so the endpoints can’t be used to enumerate valid client IDs or confirm that a code was ever real — not an oversight, and not something we’ll make more specific.
Testing it
Run the whole flow against your sandbox before submitting. It’s a real Foundry organization with real API behaviour, so what you build against is what ships — including the consent screen and the token you get back.
Two of the submission checks depend on you having done this, so it isn’t optional busywork.
This works before your app is approved. While no version has been approved, /oauth/authorize resolves your newest version whatever its state — but only your own sandbox may consent to it. Any other organization gets a 403. Once a version is approved, the flow behaves normally for everyone and your sandbox stops being special.
Foundry-built apps work differently
Apps built by Foundry — QuickBooks Sync, ShipStation — mint their key directly at install and don’t use this flow. If you’re reading this page, that isn’t you: OAuth is the path for every app we didn’t build, including in your own sandbox.
Related
- Publishing an app — access, sandbox, submission, review
- Building a partner app — what an app is and how installs work
- Partner API — the endpoints your token calls
- Webhook events — registering an endpoint and verifying signatures
- Authentication & API keys — the scope model