---
name: sage-intacct-oauth-app
description: >-
  Registers a Sage Intacct application in the Sage developer console and
  obtains an OAuth 2.0 client ID and secret for a platform that connects many
  customers' Intacct companies — covering the Web Services license (sender ID
  and password) required to register at all, the immutable
  Production/Non-production client scope, the per-customer Web Services
  subscription and sender-ID authorization, the `offline_access` scope, entity
  context in multi-entity companies, and a safe credential handoff. Use when
  asked to get Sage Intacct OAuth credentials, register an Intacct
  application, obtain a Web Services sender ID, set up the Intacct REST API,
  rotate a client secret, or fix an Intacct error like `GW-0011`, `GW-0031`,
  `XL03000006` "Invalid Web Services Authorization", a 401 or 403 on a fresh
  token, or data from the wrong entity. Sage Intacct only; for Sage Accounting
  use `sage-accounting-oauth-app`.
---

# Sage Intacct OAuth 2.0 App Registration

**Boundary:** this skill covers **Sage Intacct** and nothing else; **Sage Accounting** (Sage Business Cloud Accounting,
API v3.1) is a different product with a different portal, a different auth model and its own skill,
`sage-accounting-oauth-app`, which scopes Intacct out on purpose. They are not variants of one product — do not carry a
fact from one page to the other.

Get a working Sage Intacct REST API client — a Web Services license, a developer organisation, a registered
application, redirect URIs, and a client ID and client secret — for a platform that connects other organizations'
Intacct companies on behalf of many customers.

Intacct is closer to NetSuite than to any self-serve OAuth vendor, and four things account for almost all of the lost
time.

**You cannot register anything until Sage has issued you a Web Services license.** The registration form's very first
required field is your **sender ID**, and the next one is your **sender password**. That license is sold, not
self-served: Sage's own words are that it "is available exclusively to Sage Intacct customers and partners", obtained
from "your Sage Intacct account manager or partner representative". There is no signup button that produces one. If the
user has not got one yet, the calendar, not the console, is the blocker. That is §2 and §3.

**Credentials come in two layers, and only one of them is yours.** The sender ID, sender password, client ID and client
secret are **partner-level** — one set, issued to you, reused for every customer. What each *customer* supplies is
consent, plus three pieces of configuration inside their own company: the **Web Services subscription**, an
**authorization for your sender ID**, and a **user whose role can see the objects you sync**. None of it is visible from
your side, and all of it fails after a successful authorization. That split is the whole onboarding story, and it is §2,
§8 and §9.

**Yes, there is a real redirect-based OAuth 2.0 flow, and it is current.** Intacct's historical model — sender
credentials plus company credentials over the XML gateway — still exists and still works, but the REST API takes an
ordinary authorization-code grant at `https://api.intacct.com/ia/api/v1/oauth2/authorize`, with PKCE optional, and Sage
states that "new objects and features are released using the REST API". Register redirect URIs; they are used. That is
§5 and §14.

**The scope vocabulary is one word.** Intacct's `scope` parameter takes `offline_access`, and its only effect is
"I want a refresh token". There is no per-object scope, no read/write split, and no consent screen that enumerates
permissions. Everything the token can do is what the *authorizing user's role* can do. That is §6 and §9, and it moves
the access conversation from your registration form into the customer's user administration.

## Which Sage product this is

| This skill covers | This skill does **not** cover |
| --- | --- |
| **Sage Intacct**, REST API **v1**, host `https://api.intacct.com`, OAuth 2.0 authorization code (and client credentials), registered through the Sage developer console at `developer.sage.com/intacct` | **Sage Accounting** / Sage Business Cloud Accounting — different portal (App Registry), different API (v3.1), different scopes, different token lifetimes. Use `sage-accounting-oauth-app` |
| The legacy **XML gateway** (`/ia/xml/xmlgw.phtml`), which shares the sender-ID model and is still supported (§14) | **Sage 50**, **Sage 100**, **Sage 200**, **Sage X3**, **Sage Active**, **Sage Payroll** — separate products and portals |
| Intacct **Platform Services** triggers and outbound webhooks, insofar as they need your client ID (§13) | **Sage Construction Management**, which publishes its own API under a different host |

If the user says "Sage" and the product turns out not to be Intacct, stop and switch skills before registering anything.
An Intacct application cannot serve a Sage Accounting business and vice versa.

## Inputs to collect before you start

Ask in one batch. Never invent.

| Input | Notes |
| --- | --- |
| **Do you already have a Web Services license?** | Sender ID **and** sender password. Blocking — registration cannot start without it (§2) |
| **Which sender ID this application will bind to** | One sender ID per developer organisation. A second sender ID means a second organisation (§3) |
| **Application name** | Alphanumerics plus `.()@!?&_-` only. Appears in Sage's dashboards and reporting (§4) |
| **Contact email address** | Sage sends the approval notification here; the client ID and secret follow approval (§4, §7) |
| **Redirect URIs** | Every callback host your platform serves. HTTPS only, comma-separated (§5) |
| **Webhook receiver URLs (Async URIs)** | Registered in the same field as redirect URIs. Skip only if you are certain you will never take webhooks (§5, §13) |
| **Allowed origin domains** | Only if the browser will call the API directly with these credentials. A server-side connector usually has none (§5) |
| **Production or Non-production?** | **Cannot be changed after the application is created.** Get this right (§4) |
| **Terms and conditions URL** | Optional on the form (§4) |
| **A test Intacct company you can actually authorize against** | Ideally one you do not own, to prove the per-customer setup in §8 |
| **Will customers be multi-entity?** | Decides whether you need an entity picker or will silently read the wrong books (§10) |
| **New application, or an edit to an existing one?** | A new client ID orphans every existing customer connection (§1) |

## Quick Start

1. Confirm this is **Sage Intacct**, not Sage Accounting (table above).
2. Confirm a **Web Services license** exists — sender ID and sender password in hand (§2).
3. Confirm a **new application** is actually needed; existing connections are bound to the current client ID (§1).
4. Sign in to the console, create the **developer organisation**, and **enrol** in the Sage Intacct Developer Programme
   using the sender ID as the licence key (§3).
5. Create the application, choosing **Production** or **Non-production** deliberately — it is permanent (§4).
6. Paste **every** redirect URI and every webhook receiver URL, comma-separated, HTTPS only (§5).
7. Add the **sender password** to the application; the sender ID is prefilled (§4).
8. Wait for Sage's approval email, then capture the client ID and client secret (§7).
9. Write down the per-customer checklist — subscription, sender-ID authorization, user and role — and put it in your
   onboarding docs, because you cannot do any of it (§8, §9).
10. Decide how a customer picks an **entity** in a multi-entity company (§10).
11. Verify a real authorize → callback → token → refresh round trip against a company you do not own (§14).
12. Hand the credentials over — never commit them (§16).

## Platform state (verified 2026-09-20 — re-verify before trusting)

- **`developer.sage.com` sits behind Cloudflare bot protection and returns HTTP 403 to every non-browser client.** Each
  documentation page publishes a plain-Markdown twin at the same path plus `.md`, and those return HTTP 200 and are
  machine-readable. Every `developer.sage.com` link in **References** is given in that form. **Drop the trailing `.md`
  to open the same page in a browser.**
- **The Intacct documentation tree moved.** Live paths are
  `developer.sage.com/intacct/docs/1/sage-intacct-rest-api/...`. The older
  `developer.sage.com/intacct/docs/developer-portal/...` paths no longer resolve to those pages. Any runbook, code
  comment or bookmark still pointing at `developer-portal` is stale, and its content may be too.
- **The developer "workspace" has been renamed "organisation".** Sage's quick start says so explicitly. The console is
  reached from `developer.sage.com/intacct` via **View Console**.
- **Registration is gated twice.** First you enrol in the **Sage Intacct Developer Programme** with your sender ID as
  the *Intacct Web Services License Key*; only then can you create an application. After you submit, you "await
  confirmation and retrieve your API keys", and Sage emails the address on the form. It is not instant self-service.
- **Client scope is permanent.** Production or Non-production is chosen at creation and "cannot change the client scope
  after it's created". A web services ID "might be associated with up to five production API keys".
- **There are hard object limits per account**: organisations per user 10, users per organisation 10, applications per
  organisation 10, credentials per organisation 20 — and **only one sender ID per organisation**. Multiple sender IDs
  require multiple organisations.
- **API v1 is current.** Version is explicit in the path (`/ia/api/v1/...`). Sage deprecated **1-beta2 on
  10 December 2025**. New major versions ship only with quarterly releases, and a previous version is supported for
  **two years** after its successor appears.
- **The XML API is not dead, but it is not where new work goes.** Sage: "Intacct continues to support the XML API, but
  new objects and features are released using the REST API." Build new integrations on REST (§14).
- **As of the 2026 R1 release, an invalid authentication token returns `401 Unauthorized`**, where previous releases
  returned `400 Bad Request`. Error handling written against the old behaviour is now wrong.
- **PKCE is supported and optional.** Sage recommends it "for mobile apps and public clients where storing the client
  secret is unsafe" and documents `S256` with a 43–128 character verifier. Nothing says it is becoming mandatory.
- **Sage's own samples disagree about access-token lifetime** — `expires_in` appears as `43200` in the authorization-code
  samples and `28800` in the PKCE sample, and the token-introspection sample's `exp` minus `iat` is 10800 seconds. Read
  `expires_in` from the response; do not hardcode any of them (§11).
- **Sage documents no refresh-token lifetime at all.** There is no published expiry, no rotation rule and no statement
  that a refresh token is single-use. Treat the reconnect path as untested until you measure it (§11).

If the console does not look like this, stop and report what you actually see rather than clicking on.

## 1. Reuse the existing application, or register a new one

A new application means a **new client ID, and every existing customer connection is bound to the old one** — every
customer re-authorizes, and each re-authorization is an action inside their Intacct company that you cannot perform for
them.

Reuse the existing application for: adding a redirect URI, adding a webhook receiver, regenerating a compromised secret
(Sage: "You can generate a new key at the app registry whenever you need one"), or diagnosing a failure.

Register a **new** application only when the user explicitly wants one: a replacement for a compromised application, a
genuinely separate product — Sage's rule is that "each product or integration connected to Sage Intacct must be
registered as a separate application" — or the Non-production twin of a Production application, which *must* be separate
because client scope is immutable (§4). Say which path you are taking before you touch the console.

## 2. The crux: which credentials are partner-level and which are per-customer

This is the section to read before promising anyone an onboarding flow.

| Credential | Level | Who issues it | Where it lives |
| --- | --- | --- | --- |
| **Sender ID** | **Partner-level.** One, reused for every customer | Sage, with a Web Services developer license | Bound to your developer organisation and prefilled on your application |
| **Sender password** | **Partner-level** | Sage, alongside the sender ID | Entered once into your application; changing it needs a support case |
| **Client ID** | **Partner-level.** Public identifier, "does not change" | Sage, on approval of your application | Your config; sent in every authorize and token request |
| **Client secret** | **Partner-level.** Regenerable at the registry | Sage, on approval | Your secret store only |
| **Web Services subscription** | **Per-customer** | The customer's admin enables it | *Company > Admin > Subscriptions* in their company |
| **Sender-ID authorization** | **Per-customer** | The customer's admin adds *your* sender ID to *their* allow-list | Company Configuration → Security → Web Services authorizations |
| **The authorizing user** | **Per-customer** | The customer | Their company; the token inherits that user's permissions |
| **Company ID, user ID, entity** | **Per-customer** | The customer, at the Intacct sign-in screen | Baked into the issued token (§10) |

Two consequences that shape everything else:

- **You never collect a sender ID from a customer.** Sage is explicit that Web Services credentials "are not necessarily
  tied to a particular Sage Intacct company/user", and that "a Marketplace Partner can use a sender ID for any Sage
  Intacct company that has authorized that sender ID for Web Services". One sender ID, many companies. This is the
  opposite of the per-tenant credential collection some Intacct runbooks describe.
- **You cannot complete onboarding alone.** Consent is necessary and not sufficient. Every item in the per-customer half
  of the table is done by an administrator inside the customer's company, before or during the connect flow, and none of
  it reports back to you. §8 is the checklist to put in your onboarding documentation.

There is one shortcut worth knowing, straight from Sage's OAuth page:

> "The company to which you will be sending API requests must authorize your sender ID. You can either add your web
> sender ID to the Company Security tab or **log in as an admin user when approving the OAuth authorization request**."

So an admin who authorizes can implicitly satisfy the sender-ID authorization. A non-admin user authorizing a company
that has not allow-listed your sender ID cannot. Design the connect flow to ask for an admin the first time.

## 3. Accounts: the licence, the organisation, and enrolment

Three things, in this order. None of them is skippable.

**The Web Services license.** Sender ID plus sender password. Sage's quick start: "To register a new application and
configure your API integration as described in this quick start, you must have a Sage Intacct Web Services license.
This license consists of a sender ID and sender password and is available exclusively to Sage Intacct customers and
partners. If you do not have a web services license, contact your Sage Intacct account manager or partner
representative to obtain one." Sage's REST FAQ adds that "to access the Sage Intacct REST API, you must have a web
services developer subscription", and points at the account manager for cost. Partners go through the Sage Intacct
Marketplace partner programme. **Sender IDs are case sensitive and cannot be changed after creation**, and the password
can only be changed by opening a support case.

**The developer organisation.** "A container that houses REST API client application credentials", formerly called a
workspace. It holds your applications, generates API keys and reports usage by customer and application, and you can
invite colleagues into it. Remember: **one sender ID per organisation** (§Platform state).

**Enrolment.** From Sage's quick start, verbatim in shape:

1. Go to `https://developer.sage.com/intacct/`.
2. Select **View Console** and sign in with your Sage account email and password.
3. **Applications > New Application**.
4. For **Select an API**, select **Sage Intacct**, then **Continue**.
5. Under **Sage Intacct Developer Programme**, select **Enrol**.
6. For the **Intacct Web Services License Key**, use your **Sage Intacct Sender ID**.
7. Now that you have enrolled, select **Applications > New Application** again.
8. **Select an API** → **Sage Intacct** → **Continue**.
9. Complete the application form (§4).

Hand control back to the user for anything only a human can do: obtaining the licence, the Sage account sign-in and any
MFA, accepting terms, and the wait for Sage's approval email. Do not retry a blocked step in a loop.

**If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand the
user the ordered click path above with the literal values to paste — the redirect URIs from §5 — warn them that the
client scope in §4 is permanent, and continue once they report back with the client ID.

## 4. Create the application

The form, field by field.

| Field | Required | Notes |
| --- | --- | --- |
| **Application name** | yes | "Choose a clear, descriptive name that reflects the API use case. This name will appear in dashboards and reporting." Allowed characters: alphanumerics and `.()@!?&_-` |
| **Terms and conditions URL** | no | Sage says you can use `https://www.sage.com` if needed |
| **Redirect URIs** | yes | Full URLs, comma-separated; also the home of webhook Async URIs (§5) |
| **Origin Domains** | no | Full `https://` origins allowed to make browser-based (CORS) API calls with these credentials. A server-side connector normally leaves this empty |
| **Intacct Web Services License Key** | prefilled | Your sender ID, carried over from enrolment |
| **Intacct Web Services License Password** | yes | Your sender password |
| **Client Scope** | yes | **Production** or **Non-production**. Permanent (below) |
| **Enable Try it** | no | Non-production only; lets you fire requests from Sage's API reference pages |

Things that cost a cycle if you get them wrong:

- **Client scope cannot be changed after creation.** Production is "for live, production companies"; Non-production is
  "for sandbox or other test environments". If you need both, that is two applications — and, given the limit of ten
  applications and twenty credentials per organisation, budget for it up front.
- **A web services ID "might be associated with up to five production API keys."** That is the ceiling on production
  credential pairs behind one sender ID. Plan rotations and per-environment splits inside it.
- **Approval is asynchronous.** After **Create Application** you "await confirmation and retrieve your API keys", and
  the notification goes to the email on the form. Do not promise same-hour credentials.
- The **Usage** view in the organisation reports API consumption by application and by company, but metrics can lag by
  10 minutes after first binding the licence key, refresh every 24–36 hours, go back at most twelve months, and Sage
  warns "do not use API metrics to determine billable API totals".

## 5. Redirect URIs (and the webhook URIs that share the field)

Add **every** callback host your platform serves. For Unified.to these are one per data center; confirm the current list
with the platform owner rather than assuming:

```
https://api.unified.to/oauth/code          # us (default)
https://api-eu.unified.to/oauth/code       # eu
https://api-au.unified.to/oauth/code       # au
https://api-dev.unified.to/oauth/code      # dev
```

Rules, all from Sage:

- **Full URLs, entered as a comma-separated list.** This is not a one-per-line field.
- **"Only secure website URLs such as `https://` or mobile URI schemes are supported."** Sage additionally states that
  it requires HTTPS "for the redirect URIs used in the OAuth authorization flow".
- **The value must match exactly.** Sage: the redirect URI "must exactly match what you registered in your application
  settings". The same value must be sent at the authorize step *and* at the token exchange — both requests take a
  `redirect_uri` parameter.
- **Webhook receivers go in this same field.** Sage: "Add webhook allowable Async URIs for any endpoints that will
  receive the webhook notifications as well." If you intend to take outbound webhooks later (§13), register those hosts
  now rather than editing under pressure.
- For **local testing**, Sage's documented approach is a self-signed certificate plus a custom domain mapped to
  `localhost`, with that custom domain added to the application's callback URI list.

## 6. Scopes: one value, and it is not about permissions

Intacct's `scope` parameter takes exactly one documented value:

| Scope | Meaning |
| --- | --- |
| `offline_access` | "Use `offline_access` if you want to get a refresh token in addition to an access token" |

That is the entire vocabulary. Consequences worth saying out loud:

- **There is no per-object scope and no read/write split.** Do not build a permission-to-scope mapping; there is nothing
  to map onto. Asking for `offline_access` on a read-only integration is correct and normal — it buys a refresh token,
  not write access.
- **Access is decided by the authorizing user's role.** Sage: "your application will have the authorization of a
  specific user for each session, and thus inherit the same permissions as that user." That is §9, and it is where a
  customer's security review should be pointed.
- **Omitting `scope` is legal and means "no refresh token".** A connector that forgets it gets a short-lived access
  token and no way to renew — which looks like a token-lifetime bug and is actually a missing parameter.

## 7. Capture the credentials and endpoints

After Sage's approval email, open the application to read the client ID and client secret.

- **The client ID "does not change"** and is safe to log and to show. Its published shape is a hex string plus a Sage
  suffix, for example `<hex>.INTACCT.app.sage.com` or `<hex>.app.sage.com`. It contains dots; it is not a UUID.
- **The client secret can be regenerated** — "You can generate a new key at the app registry whenever you need one" —
  which is not the same as being re-readable. Treat regeneration as breaking: every exchange and refresh fails until the
  new value is deployed.
- Sage's own handling rules for the secret: do not store it on a mobile device, do not expose it in client-side
  JavaScript or HTML, do not put it in externally viewable server files, **and do not record it in log files or error
  messages**.

Endpoints — one host, no per-customer hostname, which is a real simplification versus other enterprise ERPs:

| Purpose | URL |
| --- | --- |
| Authorize | `https://api.intacct.com/ia/api/v1/oauth2/authorize` |
| Token (exchange and refresh) | `https://api.intacct.com/ia/api/v1/oauth2/token` |
| Revoke | `https://api.intacct.com/ia/api/v1/oauth2/revoke` |
| Introspect (RFC 7662) | `https://api.intacct.com/ia/api/v1/oauth2/introspect` |
| REST base | `https://api.intacct.com/ia/api/v1` |
| Legacy XML gateway | `https://api.intacct.com/ia/xml/xmlgw.phtml` |

Request mechanics, as Sage documents them:

- **Authorize** (GET): `response_type=code`, `client_id`, `redirect_uri`, `state`, `scope=offline_access`. The callback
  carries `code` and `state`.
- **Token exchange** (POST, `application/x-www-form-urlencoded`): `grant_type=authorization_code`, `code`,
  `redirect_uri`, `client_id`, `client_secret`. Sage's samples put the **client credentials in the body**. Its security
  page notes the client ID may be sent "in the header or in the payload", and the **revoke** endpoint explicitly accepts
  either body fields or HTTP Basic — so both styles exist, but the body form is what the token samples show.
- **Refresh** (POST, form-encoded): `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`. The
  documented response includes a **new `refresh_token`** alongside the access token.
- **PKCE**, when used: `code_challenge` plus `code_challenge_method=S256` on the authorize request, and `code_verifier`
  (43–128 characters) at the exchange.
- **`state` is your CSRF defence.** Sage asks for a value that is "unique, difficult to guess, and does not contain
  private or sensitive information", and tells you to reject a mismatch.
- **Tokens are JWTs.** Sage says so, and the introspection response shows the payload carries the client ID, the user
  ID, the company ID, the issuer, `exp`, `iat`, and — under Basic authentication — the company name and key, entity ID
  and key, user key, session ID and an AI-enabled flag. Useful for diagnostics; **do not** treat a self-decoded JWT as
  validation.
- **Bearer, spelled that way.** `GW-0034 Invalid Authorization Header` is "issued when the header value does not start
  with `Bearer`".

Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript
and can be regenerated, then move on.

## 8. What each customer must do — the part you cannot do

Put this in your onboarding documentation verbatim. Every item is an administrator action inside the customer's own
Intacct company, and every one of them fails *after* a successful OAuth authorization.

1. **Enable the Web Services subscription.** *Company > Admin > Subscriptions*. Sage lists this first among what "the
   administrator for the target company must" do. Without it, Web Services requests do not work at all — and the error
   you see is a generic gateway rejection, not "please enable the subscription" (§15).
2. **Authorize your sender ID.** Company Configuration → **Security** tab → **Web Services authorizations** → **Add** →
   enter the sender ID, optional description, status **Active** → **Save**. Sage's help is blunt: "If a sender ID is not
   on this list, any Web Services requests they make to your company will fail." **The sender ID is case sensitive** and
   must be entered exactly. The alternative, per Sage's OAuth page, is that an **admin user performs the OAuth
   authorization**, which authorizes the sender implicitly (§2).
3. **Provide a user to authorize with, and a role that can see your objects.** Sage recommends a dedicated **Web
   Services user** (*Company > Admin > Web Services Users*) — these "exchange information programmatically with Sage
   Intacct via Web Services API calls — they are not allowed to log in to the UI", their passwords do not auto-expire,
   and SSO and MFA are disabled for them. Note the tension: a Web Services user **cannot** complete the interactive
   authorization-code flow, because that flow requires a UI sign-in. So either a UI-capable user authorizes (authorization
   code, §7), or you use the **client credentials** grant with a Web Services user (below).
4. **Grant the role the permissions your integration needs.** Sage's stated best practice is to "create Sage Intacct
   users that only have access to the Sage Intacct application areas and data required by your application". That is
   exactly right and exactly the thing that later produces a 403 on one object and not another (§9).

### If you use the client credentials grant instead

For unattended access with no user present, Sage documents a second per-customer setup:

1. An admin creates a **Web Services user** (*Company > Admin > Web Services Users > Add*) with the necessary
   permissions or role.
2. An admin adds your application under *Company > Setup > Company > [Edit] > Security > **Authorized Client
   Applications** > Add*, entering your **Client ID** and the **Web Services User ID**. Sage warns: "This field is
   case-sensitive, so ensure you enter it exactly as it was created."
3. You then POST `grant_type=client_credentials` with `client_id`, `client_secret`, and either `username` or
   `session_id`. The username format is **`userId@companyId`** for top-level access, or **`userId@companyId|entityId`**
   for entity-level access.

This is *more* per-customer work than the authorization-code flow, not less — every customer configures an allow-list
entry keyed to your client ID. Use it when there is genuinely no user to consent, not to avoid the consent screen.

## 9. Roles and permissions: why a valid token still fails

Nothing about permissions is visible at registration time, and none of it is a scope.

- **The token inherits the authorizing user's permissions**, per session. A user who cannot see Accounts Payable in the
  UI cannot read bills through your token, and no amount of re-authorizing changes it.
- **The failure looks like a bad request.** Sage's own status-code table defines **403 Forbidden** as "The authenticated
  user does not have sufficient permission to run the operation or to access the target resource", and **422
  UnprocessableEntity** as "Business logic or business validation error" where "the payload conforms to the schema, but
  the values are not valid". A permission gap on one object, on an otherwise healthy connection, reads like your request
  is malformed. It usually is not.
- **Reads that work for one object and not another are the signature.** That is per-module permission, not scope, not
  token, not sender ID.
- **The remedy is a named role.** Ask the customer for a role you can state requirements against, and list the modules
  your integration touches. One sentence in an onboarding doc removes most Intacct support tickets. Sage exposes roles,
  permissions and permission assignments as REST objects under company configuration, so a support flow can read back
  what the authorizing user actually has.

## 10. Company, user and entity: three ways to read the wrong books

The token is bound to a company and a user, and — in multi-entity companies — to an entity context. Get any of them
wrong and you get **data from the wrong place rather than an error**.

**Company ID and user ID** are supplied by the customer at the Intacct sign-in screen during authorization, and land
inside the issued JWT (`cny_id`, `user_id` in the introspection response). You do not ask for them and cannot change
them after the fact. If a customer authorizes with the wrong company — easy, when a group has several — the connection
is silently wrong, and nothing in your callback says so. **Introspect the token after connecting and show the company
back to the human**; that is what the endpoint is for.

**Entity context** is the sharp one. Sage: "Multi-entity companies have a top-level parent entity with one or more
sub-entities underneath. These sub-entities may own their records." There are three documented ways to choose:

| Mechanism | How | Effect |
| --- | --- | --- |
| Default | Send nothing | The request runs against **the default entity for the access token** — top level, for a top-level token |
| Per request | `X-IA-API-Param-Entity: <entity id>` header, e.g. `CentralUS-35` | That one request runs in that sub-entity |
| Per token | Pass the sub-entity id at the **refresh** request | Returns a sub-entity access token to use for subsequent calls |

Three traps:

- **The header takes the entity's `id`, not its key or your own identifier.** Sage's example is `CentralUS-35`, the
  `company-config/entity.id` property. Passing anything else is a silent mis-read at best.
- **Sage's own refresh documentation is internally inconsistent about the parameter name.** The refresh parameter table
  names `entity_id` ("The ID of the entity that you want the user to have access to. Default is a top-level access
  token"), while the worked example a few paragraphs later sends `location_id=CentralUS-35`. Test which one your
  environment honours before shipping; do not assume from either.
- **Mixed defaults are worse than a wrong default.** If your list calls run at top level and your single-record reads or
  writes fall back to some sub-entity, the same connection reads one set of books and writes another. Pick one rule and
  apply it everywhere.

Enumerate a customer's entities from the company-configuration entity list after connecting, let a human choose, and
store the choice. Do not infer it.

## 11. Token lifetimes, sessions, and revocation blast radius

| Token | What Sage documents |
| --- | --- |
| authorization `code` | No published lifetime. Exchange it immediately |
| `access_token` | JWT. `expires_in` in the response — Sage's samples show **43200** (12 h), **28800** (8 h) and an introspection sample implying **10800** (3 h). **Read the field** |
| `refresh_token` | **No documented lifetime, no documented rotation rule, no statement that it is single use.** The refresh response does return a new `refresh_token` |

Four things follow:

1. **Never hardcode an access-token lifetime.** The three numbers in Sage's own documentation are mutually exclusive; at
   least two of them are stale or environment-specific. Trust `expires_in` and nothing else.
2. **Store whatever refresh token comes back**, and fall back to the existing one if a response omits it. That is
   correct whether or not Intacct rotates, and costs nothing.
3. **An access token can die before `expires_in`, because it rides an Intacct session.** The introspection response
   carries a `session_id`, and Sage lists "**The session no longer exists**" among the reasons a token introspects as
   inactive. Intacct session duration is a company/user setting — "The session timeout is calculated based on the
   session duration specified for the user or company plus the current time" — so a customer's own security policy can
   cut your token short. Handle 401 by refreshing, not by alerting.
4. **Revocation is not scoped to your token.** Sage, verbatim: "You may request multiple tokens for a web services
   user/company, and use them simultaneously to interact with the REST API. However, **when one of these tokens is
   revoked, the other tokens for that user/company are revoked too** and can no longer be used." If a customer runs two
   integrations through the same Web Services user, your disconnect kills theirs. Decide deliberately whether your
   "disconnect" calls revoke or merely forgets the row, and say which in your UI.

`introspect` is the cheap health check: it reports `active`, the company, the user, `exp` and `iat`. Note that **refresh
tokens always introspect as `{"active": false}`** — that is documented behaviour, not a dead connection. And a
**503 Service Unavailable** from introspect means an infrastructure problem, deliberately distinguished from an invalid
token.

## 12. Rate limits, concurrency and volume

Intacct meters **the customer's company**, on a purchased performance tier — closer to NetSuite's model than to a
per-app quota.

| Performance tier | API transactions / month | API concurrency (app/company) | Offline process concurrency |
| --- | --- | --- | --- |
| Tier 1 | 100,000 | 6/8 | 1 |
| Tier 2 | 250,000 | 8/10 | 2 |
| Tier 2-500k | 500,000 | 8/10 | 2 |
| Tier 3 | 1,000,000 | 12/15 | 3 |
| Tier 4 | 2,500,000 | 15/20 | 4 |
| Tier 5 (custom) | >2,500,000 | custom | custom |

What the numbers mean, per Sage:

- **`application/company`**: "`6/8` indicates that any single application can use up to 6 API processes at a single
  point in time, while the company can use up to 8 concurrent API processes in total." Your parallelism ceiling is the
  first number, not the second.
- **A transaction is a record, not a request.** "A request can include several records. Each record equals a transaction
  for create, update, and delete operations. A query transaction can return many records; each query counts as one
  transaction." Queries are cheap; bulk writes are not.
- **Exceeding the monthly entitlement is billed, not blocked.** "Sage Intacct does not block API transactions that
  exceed the API transaction entitlement" — customers pay an overage fee. A runaway sync costs your customer money
  rather than erroring, which is worse.
- **Marketplace-partner traffic is exempt from the customer's entitlement.** "API transactions from authorized
  Marketplace Partners, ISV solutions, and Sage Intacct's own applications do not count against the Performance Tier
  transaction entitlement purchased by a customer." A real commercial reason to pursue partner status.
- **Throttling shows up as 429 and 503**, and Sage returns `X-IA-Throttle-Limit*`, `X-IA-Minute-Rate-Limit*`,
  `X-IA-Hour-Rate-Limit*`, `X-IA-CPU-Limit*` and `X-IA-TENANT-Throttle-Limit*` response headers, with
  `-Retry-After` values. Read them; do not guess a backoff.
- **Payload ceilings**: POST/PATCH request body **1 MB**; batch request **500 operations**; query service **4,000
  records** maximum per response, paged via `start` and the response's `next`.

## 13. Webhooks: your client ID, their trigger

Intacct's outbound webhooks are not subscribed through the API. A **Platform Trigger** is configured *inside the
customer's company*, set to HTTP post, with **Use webhook delivery** selected and **your client ID** entered in the
field that then appears. Which means:

- The receiver host must be among the **Async URIs** you registered on the application (§5).
- Every customer configures their own trigger. There is no fan-out from your side.
- Delivery retries up to **four attempts** total (10 s, 30 s, 90 s) on 408, 429, 500, 502, 503 and 504; any `2xx` is
  success.
- Each request carries an **`Idempotency-Key`** (retain keys at least 24 hours), an `X-ClientContext` header, and
  `X-IA-Sage-Signature` — "Base64 encoded context information, including the client ID and the JWT token". Verify the
  signature; do not trust the body alone.

If you cannot ask customers to build triggers, plan for polling and say so up front.

## 14. XML gateway versus REST — which is current

Both exist. They share the sender-ID model and nothing else.

| | REST API (current for new work) | XML gateway (legacy, supported) |
| --- | --- | --- |
| Endpoint | `https://api.intacct.com/ia/api/v1/...` | `https://api.intacct.com/ia/xml/xmlgw.phtml` |
| Payload | JSON | XML only — "Sage Intacct currently does not support receiving JSON through Web Services" |
| Method | Standard verbs | **POST only**; GET "blocked as of January 15, 2023" |
| Version | `v1` in the path; 1-beta2 deprecated 10 Dec 2025 | `dtdversion` **3.0**; 2.1 "still supported, however it will likely not receive new product enhancements" |
| Auth | OAuth 2.0 (authorization code, PKCE, client credentials) | Two layers in the request body: sender ID + password, then company credentials (company ID / user ID / password) **or** a session ID from `getAPISession` |
| New features | Yes — "new objects and features are released using the REST API" | No |

**Build new work on REST.** Keep the XML gateway in mind for two reasons only: an existing integration may still use it,
and Sage publishes an **XML-to-REST object map** that is the fastest way to translate a customer's or a predecessor's
XML object names into REST resources.

If you do touch the XML path, Sage's guidance is to call `getAPISession` once and reuse the session rather than sending
login credentials on every call, to **validate any session ID you receive** with `getAPISession` and to confirm returned
endpoints are `*.intacct.com` domains, to keep queries under 1,000 records, and to keep record manipulation under 100 —
the sender process times out at **15 minutes**.

## 15. Sandbox and non-production

- **Sandbox is a customer-side environment**, not a developer freebie: "a copy of your production environment that
  includes all of your transactional data, configurations, and customizations", for "testing, training, and development
  purposes", with no impact on production. Sage documents Sandbox, Preview and Production environment types, and notes a
  sandbox "cannot be refreshed more than four times per subscription period" and that some features are unavailable in
  it.
- **Your application's client scope must match.** A **Non-production** application is "for sandbox or other test
  environments" and can enable **Try it**, which fires live requests from Sage's own API reference pages after you sign
  in to a non-production company. A **Production** application is for live companies. Because the scope is permanent
  (§4), a full lifecycle means **two applications and two client-ID/secret pairs**.
- There is no self-serve, Sage-hosted demo company documented in the developer portal the way other vendors offer trial
  tenants. If the user has no Intacct company to test against, that is a request to their account manager or partner
  rep — raise it on day one, not the day before the demo.

## 16. Verify end-to-end

Authorizing your own company proves almost nothing about a customer's. Work the list.

1. Run the full **authorize → callback → exchange** against a test company, with `scope=offline_access`.
2. Confirm the callback carried `code` and your `state`, and that `state` matches.
3. Confirm the token response carried `access_token`, `refresh_token` and `expires_in` — and **record which `expires_in`
   you actually got** (§11).
4. **Introspect the access token** and confirm `cny_id` is the company the human believes they connected, and that
   `entity_id` is what you expect (§10).
5. Make one read call, then one read call for an object in a different module. Two modules, because that is where role
   permissions bite (§9).
6. **Force a refresh, then force a second refresh with whatever the first returned.** This catches both a client that
   ignores a rotated token and one that assumes rotation where there is none.
7. If customers may be multi-entity, list entities, pick a sub-entity, and confirm `X-IA-API-Param-Entity` changes what
   comes back — and that your *writes* land in the same entity as your reads (§10).
8. Authorize against a company **you do not own**, with a **non-admin** user, on a company that has your sender ID
   allow-listed. That is the only test that exercises §8 honestly.
9. Deliberately test the failure you will see most: authorize against a company that has **not** allow-listed your
   sender ID, and record the exact error your platform surfaces.
10. Decide and test what your **disconnect** does, given §11's revocation blast radius.

| Symptom | Cause |
| --- | --- |
| Cannot get past the application form; the licence key field blocks you | No Web Services license — sender ID and password are a prerequisite, not an output (§2, §3) |
| Enrolment rejects the licence key | Sender ID entered with wrong case, or belongs to another organisation — one sender ID per organisation (§3) |
| `GW-0011 Invalid Request` | "a tenant not being available or not found, an inactive or invalid sender ID, an inactive or invalid client ID, or an inactive tenant" — the catch-all that a missing Web Services subscription or an un-allow-listed sender ID lands in (§8) |
| `XL03000006 Invalid Web Services Authorization` on the XML path | "The sender ID '…' is not authorized to make Web Services requests to company ID '…'" — customer admin must add it (§8, §14) |
| Flow fails before any Intacct sign-in screen | Redirect URI not registered, or not byte-identical to the one sent (§5) |
| Exchange fails with a valid-looking code | `redirect_uri` at the exchange differs from the one used at authorize (§5, §7) |
| `GW-0037 Missing Client ID` / `GW-0038 Multiple Client IDs` | `client_id` absent from, or duplicated in, the token request (§7) |
| `GW-0032 Invalid Token Payload` | `grant_type` invalid, or the token request body is not attribute-value encoded (§7) |
| `GW-0035 Missing Token Payload` | The token request has no body at all (§7) |
| `GW-0033 Invalid Token Endpoint` | The `/oauth2` sub-path is not one of authorize / token / revoke / introspect (§7) |
| `GW-0036 Missing User Name` / `GW-0012 Missing Tenant Data` | Client credentials grant with an empty `username`, or a `username` lacking the `@companyId` part (§8) |
| `GW-0014 Invalid Content Type` | XML sent to a REST endpoint — wrong API (§14) |
| `GW-0034 Invalid Authorization Header` | The header value does not start with `Bearer` (§7) |
| `GW-0031 Invalid Token` | The JWT cannot be decoded or lacks required elements (§7) |
| No refresh token in the token response | `scope=offline_access` was not sent (§6) |
| 401 on a connection that worked minutes ago | Access token expired, or the underlying Intacct session ended per the company's session-duration policy — refresh (§11) |
| Everything 401s after a colleague disconnected another integration | Revoking one token revokes every token for that user/company (§11) |
| 403 on one object, fine on others | The authorizing user's role lacks that module's permission — not a scope, not a token (§9) |
| 422 on a write that looks well-formed | Business-logic validation, often a permission or a required dimension — read `ia::error.details` (§9) |
| Data is real but from the wrong entity | No `X-IA-API-Param-Entity`, or the wrong entity id, or reads and writes using different defaults (§10) |
| Data is real but from the wrong company | The customer authorized the wrong company — introspect and show it back (§10) |
| 429, or 503 under load | Concurrency or rate ceiling for the tier; read the `X-IA-*-Limit*` headers before retrying (§12) |
| "There are too many operations running for this company" | Offline-process concurrency for the tier is exhausted (§12) |
| Monthly bill surprises the customer | Overages are charged, not blocked — transactions are records, not requests (§12) |
| Webhooks never arrive | No Platform Trigger in the customer's company, or the receiver host is not a registered Async URI (§5, §13) |
| An API version stops answering | A previous version is supported two years after its successor ships (§Platform state) |

Sage's error payloads carry a **`supportId`** (and gateway errors a `requestId`). Capture it and hand it to Support;
it is what they ask for first.

## 17. Product fact: how this platform's Sage Intacct connector is wired

**As of 2026-09-20**, and worth confirming with the connector's owner before you register anything:

- It uses the **OAuth 2.0 authorization code grant against `https://api.intacct.com`**, with the documented authorize
  and token endpoints, and one **production-only profile** — no separate non-production profile, although the registry
  forces that choice permanently at application creation (§4). A non-production deployment therefore needs a second
  registered application *and* a connector profile that does not exist yet.
- The authorize request sends `client_id`, `response_type`, `state`, `scope` and `redirect_uri`. **The scope is
  `offline_access` for every object it supports, read and write alike** — which is correct, since Intacct has no other
  scope (§6). **PKCE is not used**, which Sage permits for a confidential client.
- The token exchange and the refresh are **form POSTs carrying `client_id` and `client_secret` in the body**, matching
  Sage's samples (§7).
- It assumes a **12-hour access-token lifetime** as its fallback default. That matches one of Sage's three published
  sample values and contradicts the other two (§11). Harmless while Intacct returns `expires_in`; a latent bug if it ever
  does not.
- **It never calls the revoke or the introspect endpoint.** So disconnecting on this platform leaves the customer's
  tokens live on Sage's side — which is arguably the safer default given §11's revocation blast radius, but it should be
  a decision, not an accident, and the customer needs telling.
- After connecting it **decodes the access token as a JWT and writes the Intacct user id and company id to its
  observability backend**, then lists the company's entities and caches them on the connection, and calls the model
  service per object to cache queryable field lists. Two things to raise: the JWT decode is **not defensive** — a
  non-JWT or unparseable token would fail the whole connection setup with a type error rather than a clear message; and
  Sage's security guidance about not recording credentials in logs is worth applying to customer identifiers too.
- **Entity handling is the biggest thing to check.** It caches the **first entity returned by the company's entity list**
  as the connection's default. List operations default to **top level** (no entity header), while single-record reads,
  creates and deletes fall back to that **cached first entity**. On a multi-entity customer that means a list and a get
  can resolve to different books (§10). Worse, on those fallback paths — and on the delete path generally — the value
  passed into the entity header is the connector's own **encoded** organization identifier rather than the Intacct
  entity `id` the header expects, where the primary paths decode it first. Sage's documented header value is the plain
  `company-config/entity.id`, e.g. `CentralUS-35`. **Reproduce this on a multi-entity company before a customer does.**
- Paging uses the query service's `start`/`size` with a **page size capped at 100**, where Sage allows up to **4,000**
  records per response (§12). Conservative, but it multiplies request count — which matters because Intacct meters the
  customer's company and charges overages.
- **There is no webhook handler** — the webhook entry point is an empty stub containing only a documentation link, and
  incremental sync is polling. Consistent with §13: Intacct webhooks need a Platform Trigger built inside each customer's
  company, which is per-customer work this platform does not currently ask for.
- **There is no field anywhere for a sender ID or sender password**, which is right: those are bound to the registered
  application, not collected per connection (§2). It does expose an organization (entity) selector on list and create.
- Its recorded documentation links still point at the retired `developer-portal` documentation paths (§Platform state).
- **No plaintext credential was found** in the connector's source. The bundled copy of the vendor's own Postman
  environment carries a masked placeholder secret and a sample client ID, not a live value.

This is a snapshot of a codebase that changes. It is a starting point for the conversation, not a specification.

## 18. Hand off — never commit the secret

- **Do not** write the client secret or the sender password into source control, a test, a fixture, a committed `.env`,
  a ticket, a PR body or a chat channel. Sage says the same in its own words: do not record it in log files or error
  messages. Values go to the user, for the secret store or console.
- If a code change is needed (a redirect host, entity-header handling, refresh-token persistence, a reconnect prompt),
  keep it secret-free and say what the human must set out of band.
- Close with: the application name; the developer organisation that owns it and **which sender ID it is bound to**; the
  **client scope** (Production or Non-production) and the fact that it is permanent; the client ID; where the secret was
  delivered; the authorize, token, revoke and introspect endpoints and the REST base; the redirect URIs and Async URIs
  registered, exactly as typed; that the only scope is `offline_access`; the **per-customer checklist** from §8 to put
  in onboarding docs; how an entity is chosen (§10); and anything left for the user to do.

## Stop and ask

Hand back to a human rather than guessing when: there is **no Web Services license** and no account manager
conversation in flight (§2, §3); the sender ID already belongs to another developer organisation and a second
organisation would be needed (§3); the Production/Non-production choice is not clearly settled, because it cannot be
undone (§4); customers may be multi-entity and the connector cannot select an entity, or selects different ones for
reads and writes (§10); a customer's security review needs per-object scopes, which Intacct does not have (§6); someone
proposes calling revoke on a shared Web Services user, given the blast radius (§11); a Marketplace partner listing,
partner terms, or the transaction-entitlement exemption is in scope (§12); someone proposes regenerating a live client
secret or changing a sender password (which needs a Sage support case); the customer's product turns out to be Sage
Accounting or another Sage product (switch skills); or the console does not match the **Platform state** section above.

## References

Verified 2026-09-20 — every URL below was fetched and returned HTTP 200 on that date.

**`developer.sage.com` returns HTTP 403 to non-browser clients** (Cloudflare bot protection), so each of its links is
given in its `.md` form, which Sage serves as the page's plain-Markdown twin. **Drop the trailing `.md` to open the same
page in a browser.**

Sage Intacct REST API (current documentation tree):

- OAuth 2.0 authorization — authorize/token/revoke/introspect, PKCE, client credentials, entity-level access — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/authorization-and-security/oauth2.md
- Application security — client ID and secret handling, HTTPS, `state`, roles and permissions — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/authorization-and-security/security.md
- Quick start — Web Services license, developer organisation, enrolment, the application form, client scope — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/quick-start.md
- About developer organisations — limits, one sender ID per organisation, what redirect URI / origin domain / client ID / secret are for — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/organizations-faq.md
- Try it — non-production testing from the API reference pages — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/try-it.md
- REST API metrics — usage by application and company — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/metrics.md
- XML-to-REST object map — and the statement that new features ship on REST — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/get-started/xml-rest-object-map.md
- REST API FAQ — developer subscription, size limits, pagination, entity requests, breaking changes — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/rest-api-faq.md
- Error handling — HTTP status codes, `ia::error` payload, the `GW-` gateway error table — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/error-handling.md
- HTTP headers — `X-IA-API-Param-Entity`, rate-limit and throttle response headers — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/headers.md
- API transaction volume, concurrency and scaling — the performance-tier table — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/txn-volume-concurrency-scaling.md
- API versions — versioning policy and the 1-beta2 deprecation — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/versioning.md
- Query service — filters, sorting, pagination — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/query-service.md
- Batch, bulk and composite requests — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/api-essentials/bulk-requests.md
- Outbound webhooks — Platform Triggers, client ID binding, retries, signature headers — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/webhooks-and-triggers/webhooks.md
- Release notes index — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/release-notes.md
- Release notes 2026 R1 — invalid tokens now return 401 — https://developer.sage.com/intacct/docs/1/sage-intacct-rest-api/release-notes/2026-r1.md
- REST API reference (OpenAPI) — https://developer.sage.com/intacct/apis/intacct/1/intacct-openapi.md

Sage Intacct Developer (XML gateway and legacy):

- XML Web Services overview — sender ID / company credentials, endpoint, dtdversion, concurrency, timeouts — https://developer.intacct.com/web-services/
- Developer FAQ — the `XL03000006` "Invalid Web Services Authorization" response, sender-password changes, offline-job throughput — https://developer.intacct.com/support/faq/
- XML API reference — https://developer.intacct.com/api/
- API sessions — session duration and timeout behaviour — https://developer.intacct.com/api/company-console/api-sessions/

Sage Intacct Help Center (the customer-side setup you cannot do):

- Web Services authorizations — adding a sender ID to a company, and what happens if you do not — https://www.intacct.com/ia/docs/en_US/help_action/Company/Company_setup/Company_Information/Security/company-web-services-authorizations.htm
- Web Services users — what they are, and that they cannot sign in to the UI — https://www.intacct.com/ia/docs/en_US/help_action/Administration/Users/web-services-only-users.htm
- Web Services sender ID — obtaining sender credentials — https://www.intacct.com/ia/docs/en_US/help_action/More/Customization_and_Platform_Services/Setup/web-services-sender-id.htm
- Company environments — sandbox, preview and production — https://www.intacct.com/ia/docs/en_US/help_action/Administration/Company_environments/company-environments.htm

Commercial:

- Sage Intacct Marketplace — become a partner — https://marketplace.intacct.com/BecomeAPartner
- Sage service status — https://status.sage.com/
