Implement OAuth 2.0
Pennylane supports OAuth 2.0, an industry-standard protocol that allows applications to request access to user data without requiring user credentials.
This guide explains how to implement OAuth 2.0 with Pennylane: registering your app, authorizing users, exchanging and refreshing tokens, and revoking access when no longer needed.
Use OAuth if you are:
- An integration partner building a third-party app.
- An accounting firm accessing the Firm API with firm-level tokens.
Company customers should use API tokens instead (see Generating a Company API Token)
OAuth 2.0 Flow Overview
The process follows a standard Authorization Code flow:
(1) User clicks Sign in with Pennylane in your app.
(2) User approves access → Pennylane returns an authorization code.
(3) Your app exchanges the code for an access token (and refresh token).
(4) Use the access token in API calls.
(5) Refresh the token when it expires.
(6) Revoke the token when access is no longer needed.
For background reading, see this document.
We recommend using a well-maintained OAuth 2.0 client library for your language.
Token Types & Resource Scope
Company Token
Granted for a single company. Access is limited to that company’s resources, bounded by the requested/approved scopes (e.g., read/write products, invoices).
Firm Token
Granted at the firm level. Can access resources across multiple client companies managed by the firm. The exact list of companies (including confidential ones) depends on who granted access and their internal permissions. Scopes still apply, but visibility is filtered by the granting user’s access.
In short: scopes define what you can do, while the token context (company vs firm) and granting user define which resources you can access.
Examples
- With a company token: GET /api/external/v2/products → products for that company only.
- With a firm token: GET /api/external/v2/companies → companies visible to the granting firm user; subsequent calls must be made in the context of a selected company.
Step 1 | Registering Your Application
Before using OAuth, register your app with Pennylane by contacting our Partnerships team.
(1) Contact the Partnerships team via this form.
(2) Once validated, you will receive a Client ID and Client Secret.
The Client Secret must be kept secure and never shared.
Store both Client ID and Client Secret immediately, as they cannot be retrieved later. If they are lost, a new OAuth app must be created, which may take time.
Step 2 | Adding a “Sign in with Pennylane” Button
Redirect users to:
https://app.pennylane.com/oauth/authorize
GET Parameters
| Parameter | Required | Description |
|---|---|---|
client_id | yes | ID provided when your app is registered |
redirect_uri | yes | URL where the user is redirected after approval |
response_type | yes | Must be code |
scope | yes | Space-separated list of scopes requested |
state | no | Unique string to prevent CSRF attacks |
The user is prompted to select one of their available companies or firms and grant access.
Step 3 | Exchanging Code for Access Token
After approval, Pennylane redirects to your redirect_uri with a code (and state). Verify that the state matches what you sent to prevent tampering. Then exchange the code for tokens.
Authorization codes are single-use and expire 5 minutes after Pennylane issues them. Exchange the code as soon as you receive it. A code that has expired, has already been exchanged, was issued to a different client_id, or is sent with a redirect_uri that differs from the one used in Step 2 returns invalid_grant.
curl -X POST https://app.pennylane.com/oauth/token \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "code=AUTH_CODE" \
-d "redirect_uri=YOUR_REDIRECT_URI" \
-d "grant_type=authorization_code"Body Parameters
| Parameter | Required | Description |
|---|---|---|
client_id | yes | Your Client ID |
client_secret | yes | Your Client Secret |
code | yes | Authorization code received in Step 2 |
redirect_uri | yes | Must match the one used in Step 2 |
grant_type | yes | Must be authorization_code |
Example response
{
"access_token": "abc123...",
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": "def456...",
"refresh_token_expires_in": 7862400
}refresh_token_expires_in is the number of seconds the returned refresh token remains usable. Store it alongside the token so you can detect an expired refresh token before you attempt to use it.
Step 4 | Refreshing the Token
Access tokens expire after 24 hours. When this happens, use your refresh token to request a new access token.
Important Note on Rotation: Pennylane uses Refresh Token Rotation (RTR) for security. 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.
A refresh token that has already been used, has been revoked, or is past its 90-day validity returns HTTP 400 with "error": "invalid_grant". This error is terminal: retrying the same refresh token will never succeed, and the only recovery is to send the user through the Authorization Code flow again.
To avoid integration errors, ensure your application caches tokens properly and does not attempt concurrent refresh requests using the same token. If a refresh token expires before you can use it, you must send the user through the Authorization Code Grant flow again to generate a new token set.
curl -X POST https://app.pennylane.com/oauth/tokenBody parameters
| Parameter | Required | Description |
|---|---|---|
client_id | yes | Your Client ID |
client_secret | yes | Your Client Secret |
refresh_token | yes | Refresh token received with the access token |
grant_type | yes | Must be refresh_token |
Refresh tokens are valid for 90 days. Once used, a refresh token is revoked and replaced.
Step 5 | Revoking the Token
When access is no longer needed, revoke the token:
POST https://app.pennylane.com/oauth/revokeBody parameters
| Parameter | Required | Description |
|---|---|---|
client_id | yes | Your Client ID |
client_secret | yes | Your Client Secret |
token | yes | The access or refresh token to revoke |
Returns HTTP 200 OK with an empty body if successful.
Error Responses
All errors from https://app.pennylane.com/oauth/token follow the OAuth 2.0 error format.
invalid_grant (HTTP 400)
{
"error": "invalid_grant",
"error_description": "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
}The single error_description above covers every cause, so use the context of your request to tell them apart.
When exchanging an authorization code (grant_type=authorization_code):
- The code has expired — codes are valid for 5 minutes.
- The code has already been exchanged — codes are single-use.
- The
redirect_uridoes not exactly match the one sent in Step 2. - The code was issued to a different
client_id.
Fix: restart the flow from Step 2 to obtain a fresh code.
When refreshing (grant_type=refresh_token):
- The refresh token has already been used — rotation invalidates it the moment it is consumed, including when two concurrent requests use the same token and one of them wins.
- The refresh token, or its access token, was revoked, e.g. through
/oauth/revoke - The refresh token is past its 90-day validity.
- The refresh token was issued to a different
client_id.
Fix: send the user through the Authorization Code flow again. Do not retry the refresh request.
When Pennylane has revoked access on its side:
- The Pennylane user who granted the access has been deactivated or had their account blocked. When that happens, Pennylane immediately revokes every authorization and token granted by that user. Your access token starts returning
401 Unauthorizedon API calls even before its 24-hour expiry, and the next refresh returnsinvalid_grant.
Fix: ask the customer to reauthorize with an active user. Reauthorizing with the same deactivated user will keep failing, so your reconnection prompt should not assume the original user is still available.
Other errors
These are distinct from invalid_grant and are usually a configuration problem rather than an expired grant:
invalid_client(HTTP 401) — theclient_idorclient_secretis wrong, missing, or the client is unknown.invalid_scope(HTTP 400) — a requested scope is unknown, malformed, or not granted to your OAuth app.invalid_request(HTTP 400) — a required parameter is missing or malformed.
Best Practices
✅ Always validate the state parameter.
✅ Store token creation time and refresh before expiration.
✅ Request only the minimal scopes required (principle of least privilege).
✅ Keep your Client Secret secure.
✅ Encrypt and safely store access/refresh tokens (e.g., secrets manager).
✅ Revoke tokens when no longer needed.
✅ Treat invalid_grant on a refresh as terminal — reauthorize instead of retrying.
✅ Handle the case where the user lost granted access, by prompting for reauthorization with any active user.
Updated 13 days ago

