Docs navigation
Home / Docs / Connecting an app with OAuth

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_id and client_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_idRequired. Issued to you.
redirect_uriRequired. Must exactly match one you registered.
response_typeRequired, and must be code.
scopeOptional, space-delimited. Omit it and you get everything your approved version declared. You cannot request more than that.
stateRequired. 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 401 by asking them to reconnect.
  • It carries only the approved scopes. A call outside them returns 403, not 401. Don’t treat those the same — 403 means 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=…
CodeWhat it means
invalid_requestA required parameter is missing or redirect_uri doesn’t match a registered one.
invalid_clientUnknown client_id, or client authentication failed at the token endpoint.
invalid_grantThe code is unknown, expired, already used, issued to a different client, or paired with a different redirect_uri.
invalid_scopeYou asked for a scope your approved version doesn’t declare.
unsupported_response_typeresponse_type was not code.
unsupported_grant_typegrant_type was not authorization_code.
access_deniedThe 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.