---
name: pennylane-oauth-app
description: Establishes which Pennylane credential a connector actually needs and obtains it — partner-gated OAuth 2.0 client credentials issued by Pennylane's Partnerships team, or the self-serve Company and Firm API tokens each customer generates — with the company-vs-firm consent model, the v2 scope catalogue and the retired `ledger` scope, 24-hour access tokens with rotating 90-day refresh tokens, the v1 sunset and the 2026 behaviour migration, rate limits and a safe credential handoff. Use when asked to get Pennylane OAuth credentials, register a Pennylane app, become a Pennylane technology partner, or fix a Pennylane auth error like a 403 on every call, a 401 after a refresh, a second refresh that fails, or a customer who cannot find the Developers tab. For any other vendor's developer portal, use that vendor's skill instead.
---

# Pennylane API Credentials

**Read this first: Pennylane has no self-serve developer portal where you register an OAuth app and walk away with a
client ID and secret.** There is no "create app" button. Pennylane's own OAuth guide says it in one line — "Before
using OAuth, register your app with Pennylane by contacting our Partnerships team" — and the API contract terms put
teeth on it: "You will have to request an OAuth Client ID to Pennylane ... **Pennylane may or not grant you this
Client ID at its discretion.**" Credentials are issued by hand, to accepted partners. If the goal is "get Pennylane
OAuth credentials this week," the honest answer is that it depends on a partnership conversation you have to start
first, and the run should stop at §3 with that request drafted.

What *is* self-serve is different, and it is what a lot of Pennylane integrations actually run on: **each customer
generates their own API token inside their own account** — a **Company API token** from Settings → Connectivity →
Developers, or a **Firm API token** from Firm settings → Firm Tokens. Both are bearer tokens, both carry scopes the
customer picks, both carry an expiration the customer picks, and both are shown exactly once. Decide which of the two
models you are building against before anything else (§1).

Three more things cost real time here. **One OAuth grant can be a company grant or a firm grant, and they are not the
same API** — a firm grant reaches a portfolio of client companies through a different base path, and a connector that
assumes "one connection, one company" quietly does the wrong thing (§8). **The generation you build against matters
more than usual**: v1 is deprecated, v2 is the stable version, and a separate 2026 behaviour migration finished on
**1 July 2026**, taking the old `ledger` scope with it (§9). And **this is a French accounting product** — the ledger,
the exports and the invoicing rules are French statutory shapes, and the 2026 roadmap is dominated by French
e-invoicing (§11).

## Inputs to collect before you start

Ask in one batch. Never invent.

| Input | Notes |
| --- | --- |
| **Credential model** | Partner OAuth, per-customer Company token, or Firm token? (§1) |
| **Partner status** | Is there an open conversation with Pennylane Partnerships, or none yet? (§3) |
| **Company or firm customers** | A firm grant is a different base path and a company-selection step (§8) |
| **Integration name**, company URL, description | Asked for in the partnership request; also what the consent screen shows (§3) |
| **Redirect URIs** | Every callback host, final, up front (§4) |
| **Scope set** | The exact `resource:readonly` / `resource:all` strings the connector calls (§5) |
| **Does the client store the rotated refresh token?** | Blocking — rotation is immediate and unforgiving (§7) |
| **A Pennylane tenant to test against** | Companies self-serve a sandbox; partners request one by email (§3) |
| **New app or an edit to an existing one?** | New credentials orphan every existing customer authorization (§2) |

## Quick Start

1. Establish which **credential model** this actually needs (§1). Most wrong turns start here.
2. Check whether existing credentials are worth reusing; re-issuing orphans every existing connection (§2).
3. If partner OAuth: contact Pennylane Partnerships. This gates everything else (§3).
4. Give Pennylane **every** callback host in that same request (§4).
5. Pin the scope strings against the current v2 catalogue — not an older runbook (§5).
6. Capture the client ID and secret the moment they arrive; they cannot be re-read (§6).
7. Confirm the client handles 24-hour access tokens and **rotating** 90-day refresh tokens (§7).
8. Decide what happens when a user grants at **firm** level rather than company level (§8).
9. Confirm you are on **v2** and on the post-migration behaviour (§9).
10. Check the rate limit before promising a sync cadence (§10).
11. Verify with a real authorize → callback → refresh → refresh-again round trip (§12), then hand off (§13).

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

- **OAuth 2.0 is partner-gated.** Registration is "contact the Partnerships team"; the API agreement reserves
  Pennylane's discretion to refuse. There is no console, no form on the docs site, no way to self-register a client.
- **API v1 is deprecated; v2 is the official stable version.** Pennylane's migration page states "The V1 will be
  completely depreciated by end of 2025. The V2 is now the official stable version," and the use-case page adds that
  "New capabilities are now added in API v2, while v1 is deprecated and will not receive new features." Building
  against v1 today is building against a dead generation.
- **The separate "2026 API changes" migration has already finished.** Preview from **14 January 2026**, sunset default
  flip on **8 April 2026**, cleanup — no rollback, opt-out mechanism removed — on **1 July 2026**. As of this
  verification date all of it is live and permanent. What landed: cursor pagination on the ledger-family list
  endpoints, ledger-entry ids no longer suffixed with three digits, `created_at`/`updated_at` filtering and sorting
  removed from ledger entries, default sort flipped to descending, draft and closed-fiscal-year entries no longer
  filtered out by default, journal/ledger-account/attachment ids replaced by nested objects, and the ledger-attachment
  upload endpoint replaced by the file-attachments one.
- **The `ledger` scope is retired.** It was replaced by `journals:readonly`/`:all`, `ledger_accounts:readonly`/`:all`,
  `ledger_entries:readonly`/`:all` and `file_attachments:all`. Pennylane auto-added the new scopes on
  **14 January 2026** to OAuth apps, grants and tokens that already used `ledger`, and told everyone to have users
  re-authenticate. A new authorize URL should carry the granular scopes; an old runbook that still prints `ledger` is
  stale (§5).
- **Rate limit is 25 requests per 5 seconds on the Company API v2**, applied **per token** — so per generated OAuth
  token, not per app. The Firm API documents a different figure (5 calls per second). Both return 429 (§10).
- **Base host is `https://app.pennylane.com`**, Company API v2 under `/api/external/v2/`, Firm API under
  `/api/external/firm/v1/`. Pennylane's own 2026 migration guide prints examples against `api.pennylane.com` and the
  getting-started page prints one against `api.pennylane.com/v1/me` while its verification step uses
  `app.pennylane.com/api/external/v2/me`. **The docs contradict each other on the host** — treat `app.pennylane.com`
  as the one to build on, because it is the host used in the pages that describe v2 authentication, and confirm with a
  live `/me` call before shipping.
- **Company API tokens are plan-gated and role-gated.** "Have an Essential plan or higher" and admin access to the
  company workspace — Executive, Internal Accountant or External Accountant. A customer on a Starter plan does not see
  the Developers tab at all. Whether the same plan gate applies to an OAuth authorization is **not stated anywhere in
  the documentation**; do not assume either way.
- **`https://pennylane.readme.io/reference/scopes`** — the "official Scopes Reference" that the scopes guide links to
  as the authoritative list — **404s**. The guide page itself is the working list, and it carries its own caveat that
  it is "current as of October 2025."

If the docs or the settings screens do not look like this, stop and report what you actually see rather than clicking on.

## 1. Which credential, and who issues it

A reader arriving here is usually holding the wrong one. Pennylane runs three API surfaces, and the credential decides
which one you get:

| Credential | Who issues it | Reaches | Notes |
| --- | --- | --- | --- |
| **Company API token** | The customer, self-serve, in their own company workspace | That **one** company, Company API v2 | Essential plan or higher; admin role; scopes and expiry chosen at creation; shown once |
| **Firm API token** | The customer, self-serve, in their firm account | The firm's **portfolio** of client companies, Firm API | One token per firm; a firm can hold several tokens with different scopes |
| **OAuth 2.0 client** | **Pennylane**, to accepted partners, at its discretion | Whatever the granting user picks — a company **or** a firm | The only model that scales to many customers without asking each for a secret |

Pennylane's own guidance is blunt about the split: use OAuth if you are "an integration partner building a third-party
app" or "an accounting firm accessing the Firm API with firm-level tokens," and "Company customers should use API
tokens instead."

Two traps follow directly:

- **A Firm API token is not a Company API token with more reach.** It authenticates against a different base path and
  a different reference; endpoints, scopes and even the rate-limit figure differ. Treat firm support as a separate
  piece of work, not a flag.
- **Every token is single-company or single-firm.** A customer with three Pennylane companies needs three tokens and
  three connections. There is no account-discovery endpoint on a company token.

> **Product fact — dated.** As of **2026-09-20**, Unified.to's Pennylane connector is **OAuth 2.0 only** — it offers no
> customer-supplied-token mode, so a customer who only has a Company API token cannot connect. It targets the Company
> API v2 base path on the `app.pennylane.com` host, points its authorize and token calls at that same host, and is
> configured to use **its own registered credentials** rather than any shared platform credentials — meaning a
> deployment must hold a Pennylane-issued client ID and secret of its own before a single customer can connect. It
> records Pennylane's rate limit as **5 requests per second** (the figure the Firm API publishes, not the 25-per-5-
> seconds the Company API v2 page publishes), records the sandbox route as an email to Pennylane's partnerships
> address, and stores a Typeform page titled "OAuth registration form" as the place to obtain credentials — which is
> **not** the partnership contact form Pennylane's own OAuth guide links to. Confirm both with Pennylane and with the
> connector's owner before sending anyone to either.

## 2. Reuse the existing credentials, or request new ones

New credentials mean a **new client ID, and every existing customer authorization is bound to the old one** — every
customer re-authorizes. Reuse what exists for: adding a redirect URI, adding or migrating a scope, rotating a
compromised secret, or diagnosing a failure.

Request a **new** client only when the user explicitly wants one: a separate product, a replacement for a compromised
client, or a deliberate test client. Note Pennylane's warning about losing a secret — "If they are lost, a new OAuth
app must be created, which may take time" — which means a lost secret is not a five-minute rotation here, it is a new
app and a full customer re-authorization. Guard the secret accordingly (§13).

For **per-customer tokens** the calculus is much gentler: each customer's token is independent, so deleting or
regenerating one affects exactly one company or firm. Say which path you are taking before you start.

## 3. Getting the credentials: the partnership route

### Path A — Partner OAuth (what a multi-customer connector needs)

1. Contact Pennylane's **Partnerships team**. The OAuth guide links a partnership contact form on
   `pennylane.com`; the getting-started page gives `partnerships@pennylane.com` as the integrator channel. Use both
   if unsure which is current.
2. Say what the integration does, which scopes it needs and why, and hand over **every** redirect URI (§4). Pennylane
   does not document a self-service place to add one later, so treat the list as final at request time.
3. "Once validated, you will receive a Client ID and Client Secret."
4. **Store both immediately.** Pennylane: "Store both Client ID and Client Secret immediately, as they cannot be
   retrieved later."

Pennylane publishes **no review timeline, no cost, no SLA and no eligibility bar** for this. Do not invent one. The
technology-partner page describes a three-stage path — development, **certification** ("Faites certifier votre
intégration"), then publication to the marketplace — but does not state what certification tests, how long it takes,
or whether it gates API access as opposed to listing. Say that it is unknown rather than guessing.

### Path B — Company API token (self-serve, available today)

What a customer does in their own account, needing nothing from Pennylane:

1. They must be on an **Essential plan or higher** and hold admin access to the company workspace (Executive, Internal
   Accountant or External Accountant).
2. **Settings → Connectivity → Developers** → **Generate an API Token**.
3. Name it, choose **Read only** or **Read and write** under **API V2**, choose an expiration — **1 month, 6 months,
   12 months, or Unlimited** — and generate.
4. Copy it immediately. "Tokens are not stored in Pennylane and cannot be retrieved later."
5. A company can hold several tokens with different scopes; each request is judged on the scopes of the token used.
   The right ask is one narrow token per integration, not one shared token for four vendors.
6. Deleting a token is irreversible and immediate.

### Path C — Firm API token

**Firm settings → Firm Tokens → Generate an API Token**, then name, scopes, expiration, copy once. One token per firm;
several tokens per firm are allowed for different scope combinations.

### A tenant to test against

- **Companies** self-serve a sandbox: profile menu → **Test environment** → **Create my sandbox**. They end up with a
  live account and a separate sandbox account.
- **Firms** use their existing firm account; no sandbox is created.
- **Integrators** email `partnerships@pennylane.com` with a full name and the email address to use for sandbox
  creation. There is no self-serve partner sandbox.

Rate limiting is enabled on sandbox as well as production, so a sandbox does not buy you head-room for a load test.

Anything only a human can do — signing anything, email verification, being made an admin, upgrading a plan, asking for
a sandbox — hand back rather than looping.

**If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Give
the user an exact, ordered click path with the literal values to paste — the redirect URIs from §4 and the scope
strings from §5 — or a ready-to-send partnership request for Path A, then continue once they report back.

## 4. Redirect URIs

Add **every** callback host your platform serves, at request time. 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
```

What Pennylane documents: `redirect_uri` is required on the authorize call, and the one sent at the token exchange
"Must match the one used in Step 2."

What Pennylane does **not** document, anywhere on its docs site: how many redirect URIs an app may hold, whether
wildcards or subpaths are accepted, whether `http://localhost` is allowed for development, the matching rule
(prefix or exact), or how to add one after the app is issued. **Do not state any of these as fact.** Assume exact
string matching, no trailing-slash forgiveness, and that adding a region later is an email and a wait — then ask
Pennylane to confirm when you request the client.

## 5. Scopes

Scopes are **space-separated** in the `scope` query parameter, and every one follows the same pattern:
`resource:readonly` for GET-only, `resource:all` for read + write + delete. A token missing the required scope gets
**403 Forbidden**, not a 401.

The v2 catalogue, as Pennylane's scopes guide spells it:

| Domain | Scopes |
| --- | --- |
| Sales | `customers:readonly` / `:all`, `products:readonly` / `:all`, `customer_invoices:readonly` / `:all`, `quotes:readonly` / `:all`, `customer_mandates:readonly` / `:all`, `billing_subscriptions:readonly` / `:all`, `commercial_documents:readonly` / `:all`, `customer_invoice_templates:readonly` |
| Purchases | `suppliers:readonly` / `:all`, `supplier_invoices:readonly` / `:all`, `purchase_requests:readonly` / `:all` |
| Accounting | `journals:readonly` / `:all`, `ledger_accounts:readonly` / `:all`, `ledger_entries:readonly` / `:all`, `trial_balance:readonly`, `fiscal_years:readonly`, `exports:fec`, `exports:agl`, `exports:gl`, and the retired `ledger` |
| Analytics | `categories:readonly` / `:all` |
| Banking | `transactions:readonly` / `:all`, `bank_accounts:readonly` / `:all`, `bank_establishments:readonly` |
| Core / shared | `file_attachments:readonly` / `:all` |

Four things that look like the portal being broken and are not:

1. **`ledger` is gone.** It only ever worked under the pre-2026 behaviour, and that behaviour was removed on
   1 July 2026. Replace it with the granular accounting scopes plus `file_attachments:all`. Apps that held `ledger`
   before 14 January 2026 had the new scopes added automatically — but their **already-issued access tokens** do not
   carry them until the user re-authorizes.
2. **Scope changes need re-consent.** An existing token keeps the scopes it was minted with. Adding a scope to the
   authorize URL changes nothing for connected customers until each one goes through the flow again.
3. **`resource:all` is not "read plus a bit."** It is read + write + **delete**. Asking for `:all` where the connector
   only reads is an over-scope that a certification review can reasonably object to, and Pennylane's guide leads with
   least privilege.
4. **`/me` is the cheap way to settle an argument.** It needs no dedicated scope, works for every valid token, and
   returns the user, the company context and the token's active scopes.

> **Product fact — dated.** As of **2026-09-20**, Unified.to's Pennylane connector requests scopes **per unified object
> and per direction** rather than one blanket list, sends them space-delimited, and does **not** use PKCE. Its
> accounting-ledger objects still request the **retired `ledger` scope** for both read and write — the single most
> likely thing in this configuration to be broken, given that scope stopped working on 1 July 2026. Its invoice objects
> request `draft_invoices:all` alongside `supplier_invoices:all`, `customer_invoices:all` and `categories:all`;
> **`draft_invoices:all` does not appear anywhere in Pennylane's v2 scope catalogue or documentation index**, so it is
> either a v1 leftover or undocumented — verify before trusting it. Several read-only objects request write-level
> `:all` scopes (customers, suppliers, products, customer and supplier invoices, categories), and the bank-transaction
> object maps its *write* direction to the read-only `transactions:readonly`. Organization data is requested with
> `fiscal_years:readonly`, bank accounts with `bank_accounts:readonly`, file storage with `file_attachments:readonly`
> for read and `file_attachments:all` for write, and analytical categories with `categories:readonly` / `:all`. Two
> objects request no scopes at all. Confirm all of this with the connector's owner before you quote a scope list to
> Pennylane — the `ledger` and `draft_invoices:all` entries in particular.

## 6. Capture the credentials

- **Client ID and client secret** — delivered by Pennylane when the app is validated. **Neither can be retrieved
  later**; losing them means requesting a new app.
- **Authorize** — `https://app.pennylane.com/oauth/authorize`, GET, carrying `client_id`, `redirect_uri`,
  `response_type=code`, `scope` (space-separated) and `state`. Pennylane marks `state` optional; treat it as
  mandatory, because it is your only CSRF defence.
- **Token and refresh** — `https://app.pennylane.com/oauth/token`, POST. Pennylane's documented example is a
  form-encoded body carrying `client_id`, `client_secret`, `code`, `redirect_uri` and `grant_type=authorization_code`.
  A client posting JSON instead is not what the docs show; if the exchange fails with a credentials-shaped error,
  check the encoding before blaming the secret.
- **Revoke** — `https://app.pennylane.com/oauth/revoke`, POST with `client_id`, `client_secret` and `token` (access or
  refresh). Returns 200 with an empty body.
- **Environment** — there is no separate sandbox host. A sandbox is a separate *account* on the same endpoints (§3).

Report the secret once so the user can paste it into their secret store, say plainly that it is now in the transcript
and what it would cost to replace, then move on.

## 7. Token behaviour: 24 hours, 90 days, and rotation on every use

| Token | Lifetime | Rule |
| --- | --- | --- |
| `access_token` | **24 hours** (`expires_in: 86400`) | Bearer, sent in the `Authorization` header |
| `refresh_token` | **90 days** | **Rotates on every use.** Using it immediately invalidates it *and* the old access token |
| authorization `code` | not documented | Single use; exchange it immediately |

Pennylane calls the rotation behaviour RTR and is explicit about the consequences: "Every time you use a refresh
token, it is immediately invalidated, and a brand new refresh token is returned alongside your new access token. Once
a refresh token is used, both that token and the old access token will no longer work."

Three ways this bites:

1. **A client that reuses the original refresh token succeeds once and fails on the second refresh.** Persist the new
   refresh token in the same transaction as the new access token.
2. **Concurrent refreshes destroy each other.** Pennylane names this directly — "does not attempt concurrent refresh
   requests using the same token." Two workers refreshing the same connection at once will leave one of them holding a
   dead token. Serialise refreshes per connection.
3. **There is no documented grace window.** Unlike some vendors, Pennylane does not say the previous refresh token
   keeps working for a few minutes. Assume it does not. If a refresh response is lost in flight, the connection may
   already be unrecoverable and the customer must re-authorize.

A refresh token that passes 90 days unused is dead and the customer must go through the authorization-code flow again.
Compute and store an expiry at every refresh; nothing in the response tells you when the *refresh* token dies.

## 8. Company or firm — what the user actually granted

The consent screen is not a yes/no. Pennylane: "The user is prompted to select one of their available companies or
firms and grant access." What comes back is one of two very different things:

- **A company token** — scoped to that single company. `GET /api/external/v2/products` returns that company's products
  and nothing else. This is the shape most connectors assume.
- **A firm token** — scoped to the firm. It reaches the firm's portfolio of client companies through the **Firm API**
  at `/api/external/firm/v1/`, starting with a companies listing (`companies:readonly`), and "subsequent calls must be
  made in the context of a selected company." Which companies are visible, including confidential ones, depends on the
  granting user's internal permissions — so two users at the same firm can produce different portfolios.

Consequences worth stating out loud before a customer is promised anything:

- A connector that only knows the Company API base path will **not** work against a firm grant. It will not fail
  loudly and helpfully either; the endpoints simply are not there.
- "One connection, one company" is a defensible model, but then the connect flow has to tell the customer to grant at
  company level, and a firm user has to make one connection per client company.
- The Firm API is a separate reference with its own scopes (including firm-only ones such as `companies:readonly` and
  the document-management `dms_files` scopes) and its own published rate limit. Treat firm support as a project.

**Product fact — dated.** As of 2026-09-20, Unified.to's Pennylane connector targets the **Company API v2 path only**
and has no company-selection step after authorization — it neither lists companies nor stores a company identifier
against the connection, relying entirely on the token being a company-scoped token. A customer who grants at firm level
should be expected to produce a connection that authorizes cleanly and then reads nothing useful. Confirm with the
connector's owner before telling an accounting firm it is supported.

## 9. Which generation you are building against

This is the expensive mistake in this vendor, so check it explicitly rather than inheriting it from an old runbook.

- **v1 is deprecated** and was slated for complete deprecation by end of 2025; it receives no new features. Its paths
  carry `/v1`. If anything you are reading, testing against or copying uses a `/v1` path on the Company API, it is the
  wrong generation.
- **v2 is the official stable version**, at `/api/external/v2/`. Its hallmarks: granular scopes, **cursor-based
  pagination**, one endpoint per resource type on create, file attachments as a first-class resource uploaded before
  use, and floats passed as **strings** rather than numbers.
- **The 2026 behaviour migration is finished** (§ Platform state). The `X-Use-2026-API-Changes` header and its
  `use_2026_api_changes` query-parameter twin were the opt-in/opt-out lever during preview and sunset; after the
  cleanup phase on **1 July 2026** the mechanism is disabled. Code still sending either is at best a no-op — and
  sending the header and the query parameter with conflicting values returned **400 Bad Request** while it was live.
- The migration's sharpest edge for a sync engine: **`created_at` / `updated_at` filtering and sorting were removed
  from the ledger-entries listing.** An incremental sync built on "give me everything updated since X" has to move to
  id-based paging, the business `date` field, or the changelog endpoints. The changelogs retain **four weeks** of
  changes — a connector that falls further behind than that cannot catch up from them.
- Ledger-entry ids lost their three-digit suffix, so ids captured before the flip do not match ids fetched after it.
  Anything that stored them needs a re-key, not a re-sync.

**Product fact — dated.** As of 2026-09-20, Unified.to's Pennylane connector calls the **v2** Company API throughout
(`/api/external/v2/…`) and uses cursor pagination on the object where it reads bank transactions, so it is on the right
generation. It does not send the 2026 migration header, which is correct now that the mechanism is retired.

## 10. Rate limits

- **Company API v2: 25 requests per 5 seconds**, enforced **per token** — "If you have an OAuth app, it will be
  applied on each generated token from your OAuth app." So each customer connection gets its own budget; your app as a
  whole is not throttled as one.
- **The Firm API publishes a different figure: 5 API calls per second.** Do not quote one at the other.
- Rate limiting is on in **sandbox as well as production**, and all endpoints are affected.
- Exceeding it returns **429** with a plain-text body naming the wait. **Honour the headers rather than guessing**:
  `retry-after` (seconds, 429 only), `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset` (a Unix timestamp).
  The three `ratelimit-*` headers are returned on successful responses too, so a sync can steer before it hits the wall.
- Pennylane's own retry guidance: retry 429, 500 and 503; do not retry 400, 401, 403, 404 or 422.

## 11. This is a French accounting product

Not a style note — it changes what the integration is allowed to do and what customers will ask for.

- **The ledger is a French statutory ledger.** The exports are French statutory exports: **FEC** (`exports:fec`) is the
  *fichier des écritures comptables* the tax administration requires, alongside the general ledger and the analytical
  general ledger (`exports:gl`, `exports:agl`). These are scope-gated separately from ordinary ledger reads.
- **Ledger entries must balance** — debit total equals credit total — and Pennylane will reject ones that do not.
- **Invoice numbering is sequential by law.** Pennylane's own testing guide tells you to configure invoice numbering
  before testing invoicing, and points at a French-language help article to do it. A connector that creates invoices is
  inside a legally constrained numbering sequence, not a free-form document store.
- **Fiscal periods close and freeze.** Post-migration, the ledger-entry listing no longer hides entries outside the
  open fiscal year by default, and `date` can now be null — both of which change what a naive sync pulls.
- **French e-invoicing dominates the 2026 roadmap.** The changelog through 2026 is Factur-X conversion on invoice
  import, e-invoicing flow fields and filters, sending customer e-invoices to an approved platform, and validation
  status fields. If the product touches French invoicing, this is where the requirements will come from.
- **Much of the surrounding material is in French** — the partner programme pages, the help centre articles the docs
  link to, the token-generation screens. The API reference itself is in English. Read the French pages rather than
  skipping them; that is where the partnership and certification wording lives.
- **Two clauses in the API agreement are worth a lawyer's eye, not yours**: Pennylane takes an exclusive, perpetual,
  irrevocable, sublicensable licence to your API Integration and may request its source code; and the restrictions
  forbid using the API to offer services similar to, or competing with, Pennylane's. Flag them; do not interpret them.

## 12. Verify end-to-end

Do not stop at "the token came back."

1. Call `/me` first. It needs no scope and tells you the user, the **company context** and the token's **active
   scopes** — which settles most scope arguments in one request.
2. Confirm the token response carries a **refresh token** and note the 24-hour `expires_in`.
3. Make one real read against a v2 path and check the response carries `ratelimit-*` headers (proof you are on the
   metered v2 surface).
4. **Refresh, then refresh again using the token the first refresh returned.** Two minutes of work; it is the only
   thing that catches a client ignoring rotation.
5. Read a **ledger-family** endpoint — journals or ledger entries — because that is where the retired `ledger` scope
   and the new cursor pagination both bite.
6. Authorize a **second** account, and if firms are in scope, deliberately grant at **firm** level and see what your
   connector does (§8).
7. Watch the rate-limit headers during a real sync, not a single call.

| Symptom | Cause |
| --- | --- |
| 403 on every call, token looks valid | Missing scope for that endpoint — check `/me` for the token's active scopes (§5) |
| 403 on ledger, journals or ledger accounts specifically | Still requesting the retired `ledger` scope instead of the granular accounting scopes (§5) |
| 401 on every call | Token missing, malformed or revoked; check the `Authorization` header (§6) |
| 404 on every call, credential is valid | Wrong base URL or wrong generation — a `/v1` path, or the firm path against a company token (§8, §9) |
| Authorization succeeds, then no data anywhere | The user granted at **firm** level and the connector only speaks the Company API path (§8) |
| Second refresh fails, the first one worked | Client is not storing the rotated refresh token (§7) |
| Refresh fails intermittently under load | Concurrent refreshes on one connection — the loser gets a dead token (§7) |
| Worked for months, dead today, nothing changed | A customer's self-serve token hit its chosen expiration, or a 90-day refresh token went unused (§3, §7) |
| Customer says there is no Developers tab | They are on a Starter plan, or lack the admin role (§3) |
| Incremental sync silently stops finding changes on ledger entries | `created_at`/`updated_at` filtering was removed in the 2026 migration (§9) |
| Stored ledger-entry ids no longer resolve | The three-digit id suffix was dropped in the 2026 migration — re-key, do not re-sync (§9) |
| `400 Bad Request` mentioning the 2026 changes | Header and query parameter sent with conflicting values, back when the lever existed (§9) |
| 429 under light load | 25 requests / 5 seconds per token — honour `retry-after` and `ratelimit-reset` (§10) |
| 422 on a ledger entry create | Entry does not balance, or a float was sent as a number instead of a string (§9, §11) |

## 13. Hand off — never commit the secret

- **Do not** write the client secret, an API token or a refresh token into source control, a test, a fixture, a
  committed `.env`, a ticket, a PR body or a chat channel. Values go to the human running this, for their secret store
  or console.
- Pennylane's secret is **not re-readable and not rotatable in place** — losing it means a new app and a full customer
  re-authorization. Say so when you hand it over; it raises the care people take.
- If a code change is needed (a callback host, a scope migration, storing rotated refresh tokens), keep it secret-free
  and say plainly what the human must set out of band.
- Close with: which credential model you ended up on; the client ID if partner OAuth; where the secret was delivered;
  the authorize, token and revoke endpoints; the exact scope strings, with any retired or undocumented ones called
  out; whether the client stores rotated refresh tokens and serialises refreshes; whether company-level or firm-level
  grants are supported; the rate limit the connector will actually get; and whatever is still waiting on Pennylane.

## Stop and ask

Hand back to a human rather than guessing when: **there is no partnership conversation with Pennylane** and the ask is
OAuth credentials (say so in the first reply — do not start a registration that cannot complete); the client does not
persist rotated refresh tokens or refreshes concurrently (report it, do not register and hope); a customer needs
**firm-level** access the connector does not implement (§8); anything still targets **v1** or assumes the pre-July-2026
ledger behaviour (§9); a scope in the current configuration is not in Pennylane's published catalogue (§5); the
certification, marketplace-listing or partnership terms ask for commercial, security or compliance claims; the API
agreement's exclusive-licence and non-compete clauses are relevant to the product (§11); French e-invoicing or
statutory-export obligations are in scope and nobody on the team owns that question; or the documentation and settings
screens do not match the **Platform state** section.

## References

Verified to resolve on 2026-09-20.

- Implement OAuth 2.0 (registration, authorize, token, refresh, revoke, lifetimes) — https://pennylane.readme.io/docs/oauth-20-walkthrough
- Understand Scopes (the v2 scope catalogue) — https://pennylane.readme.io/docs/v2-scopes
- Introduction to the Pennylane API (auth paths, sandbox creation, `/me`) — https://pennylane.readme.io/docs/getting-started
- Choose the Right API (Company vs Firm vs Firm Group) — https://pennylane.readme.io/docs/what-apis-are-available
- Create a Company API Token (plan gate, roles, expirations) — https://pennylane.readme.io/docs/generating-my-api-token
- Create a Firm API Token — https://pennylane.readme.io/docs/firm-api
- Migrate from API v1 to v2 (v1 deprecation) — https://pennylane.readme.io/docs/api-v2-vs-v1
- 2026 API changes Migration Guide (phases, `ledger` scope retirement, pagination, id changes) — https://pennylane.readme.io/docs/2026-api-changes-guide
- Rate Limiting in API v2 — https://pennylane.readme.io/docs/rate-limiting-1
- Error Handling & Status Codes — https://pennylane.readme.io/docs/error-handling-status-codes
- Use Cursor-Based Pagination — https://pennylane.readme.io/docs/using-cursor-based-pagination
- Supported Use Cases (and what is not supported) — https://pennylane.readme.io/docs/supported-use-cases
- Webhooks — https://pennylane.readme.io/docs/webhooks
- API Changelog — https://pennylane.readme.io/changelog
- API contract terms (client-ID discretion, licence and restriction clauses) — https://pennylane.readme.io/page/api-contract-terms
- How to reach out — https://pennylane.readme.io/docs/how-to-reach-out-to-us
- Full documentation index — https://pennylane.readme.io/llms.txt
- Firm API reference — https://firm-pennylane.readme.io/
- Firm API scopes — https://firm-pennylane.readme.io/reference/scopes
- Firm API rate limiting — https://firm-pennylane.readme.io/reference/rate-limiting
- Partnership contact form (linked from the OAuth guide) — https://www.pennylane.com/fr/contact-demande-de-partenariat/
- Technology partner programme (development → certification → publication) — https://www.pennylane.com/fr/partenaires/partenaire-technologique
