Sage Business Cloud Accounting API: Capabilities, Rate Limits, and Integration Patterns
What are the Sage Business Cloud API capabilities and integration patterns?
TL;DR
- Bottom line: Sage Business Cloud Accounting exposes a REST API (v3.1) for invoicing, contacts, bank accounts, ledger accounts, and tax operations across 10+ countries. Token management is the hardest part — 5-minute access tokens with rotating refresh tokens require careful implementation.
- Key limit: 2,500 API requests per day per company and 100 requests per minute per company. Access tokens expire in 5 minutes.
- Watch out for: Refresh tokens rotate on every use and expire after 31 days of non-use. Losing a refresh token means the user must re-authorize interactively — there is no recovery path.
- Best for: SMB accounting integrations (invoicing, contacts, bank reconciliation) with low-to-medium data volumes (<2,500 operations/day per company).
- Authentication: OAuth 2.0 authorization code flow only. No client credentials (S2S) flow — every integration requires initial user authorization. Access token: 5 min, refresh token: 31 days (rotating).
System Profile
Sage Business Cloud Accounting (formerly Sage One) is Sage's cloud-native accounting platform targeting small and medium businesses. The API v3.1 is the current version and provides access to core accounting entities including contacts, invoices, bank accounts, ledger accounts, products, services, tax rates, payments, and journal entries. The API serves multiple countries (UK, Ireland, US, Canada, France, Spain, Germany, Australia, and others) with a multi-business routing model via the X-Business header. This card does NOT cover Sage Intacct (enterprise-grade, separate SOAP/REST API), Sage X3 (GraphQL API), Sage 50 (desktop SDK), or Sage 100 (on-premise).
| Property | Value |
|---|---|
| Vendor | Sage |
| System | Sage Business Cloud Accounting |
| API Surface | REST (JSON) |
| Current API Version | v3.1 |
| Editions Covered | All Sage Accounting cloud tiers |
| Deployment | Cloud |
| API Docs | Sage Accounting Developer Portal |
| Status | GA |
API Surfaces & Capabilities
Sage Business Cloud Accounting exposes a single REST API surface. There is no SOAP API, no Bulk API, and no native webhook/event-driven surface — polling is the only change detection mechanism.
| API Surface | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| REST API v3.1 | HTTPS/JSON | Individual CRUD, list queries, invoicing | 200 per page | 100/min per company, 2,500/day per company | Yes | No |
| Swagger/OpenAPI | JSON spec | API discovery, client generation | N/A | N/A | N/A | N/A |
Key Endpoints
| Endpoint Category | Examples | Operations |
|---|---|---|
| Contacts | /contacts, /contact_types | GET, POST, PUT, DELETE |
| Sales | /sales_invoices, /sales_credit_notes, /sales_quotes | GET, POST, PUT, DELETE |
| Purchases | /purchase_invoices, /purchase_credit_notes | GET, POST, PUT, DELETE |
| Banking | /bank_accounts, /bank_transfers | GET, POST, PUT, DELETE |
| Ledger | /ledger_accounts, /journals | GET, POST, PUT, DELETE |
| Products & Services | /products, /services | GET, POST, PUT, DELETE |
| Tax | /tax_rates, /tax_types | GET, POST, PUT, DELETE |
| Payments | /contact_payments, /contact_allocations | GET, POST, PUT, DELETE |
| Settings | /business_settings, /financial_settings | GET, PUT |
Rate Limits & Quotas
Per-Request Limits
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max records per page | 200 | List endpoints (GET) | Default is 20; set via items_per_page parameter |
| Max request body size | ~1 MB (practical) | POST/PUT | Single record per request — no batch endpoint |
| Data transfer limit | 100 MB | Per app | Rolling 60-minute window |
| Max concurrent requests | 150 | Per app (all companies) | Across all companies your app serves |
Rolling / Daily Limits
| Limit Type | Value | Window | Notes |
|---|---|---|---|
| API requests per company | 2,500 | 24h rolling | Per-company, shared across ALL apps accessing the same company |
| API requests per minute | 100 | 1 minute | Per-company rate limit |
| API requests per app | 1,296,000 | 24h | App-level aggregate across all companies |
| Failed login attempts | 20 | 1 hour | IP blocked for 1 hour after exceeding |
Authentication
Sage Business Cloud Accounting uses OAuth 2.0 authorization code flow exclusively. There is no client credentials (server-to-server) flow — every integration requires initial interactive user authorization.
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 Authorization Code | All integrations — user authorizes app access | Access: 5 min; Refresh: 31 days | Yes (rotating) | Only available flow. User must interactively authorize. [src2, src5] |
Token Lifecycle
- Authorization: Redirect user to Sage's OAuth endpoint with
response_type=code - Token exchange: POST to
https://oauth.accounting.sage.com/tokenwithgrant_type=authorization_code - Access: Include token as
Authorization: Bearer {token}header. Token valid for 5 minutes. - Refresh: POST with
grant_type=refresh_token. Returns new access token AND new refresh token. Previous refresh token is invalidated. - Business selection: Call
GET /businessesto discover available businesses, then includeX-Business: {business_id}header on all requests.
Authentication Gotchas
- 5-minute access tokens are aggressively short: You must implement proactive token refresh before every batch of API calls. [src5, src7]
- Refresh tokens rotate on every use: Each refresh call invalidates the previous refresh token and issues a new one. If you use the same refresh token twice (race condition), both tokens are invalidated. Serialize refresh operations. [src5, src7]
- Refresh tokens expire after 31 days of non-use: If your integration goes 31 days without refreshing, the user must re-authorize interactively. Plan scheduled keep-alive refreshes. [src5]
- X-Business header is required for multi-business users: After OAuth, you cannot determine business access from the token itself. You must call
GET /businessesand present a selection to the user. [src5, src7] - No client credentials flow: Unlike most ERP APIs, Sage Accounting has no S2S/daemon flow. Every integration requires user interaction at initial setup and potentially every 31 days. [src2]
Constraints
- No native webhooks or event streaming — change detection requires polling with
updated_fromandupdated_tofilters. - 2,500 requests/day per company is extremely low compared to enterprise ERPs. A full data sync can exhaust the daily limit in a single run.
- No bulk/batch API — every record must be created, updated, or deleted individually.
- Single record per write operation — POST/PUT endpoints accept one record per request.
- No S2S/daemon authentication flow — all integrations require initial interactive user authorization and periodic re-authorization if refresh tokens expire.
- Tax and regulatory fields vary by country — the same endpoint returns different fields depending on the business's country.
Integration Pattern Decision Tree
START — User needs to integrate with Sage Business Cloud Accounting
|-- What's the integration pattern?
| |-- Real-time (individual records, <1s)
| | |-- Data volume < 100 records/operation?
| | | |-- YES --> REST API: one request per record
| | | |-- NO --> REST API with rate limit throttling (max 100/min)
| | |-- Need change notifications?
| | |-- YES --> Poll with updated_from filter (no webhooks)
| | |-- NO --> Direct REST API calls
| |-- Batch/Bulk (scheduled, high volume)
| | |-- Data volume < 2,500 records/day?
| | | |-- YES --> REST API with sequential requests + rate limiting
| | | |-- NO --> STOP — 2,500/day limit cannot be raised
| | | |-- Consider Sage Intacct (100K+ transactions/month)
| |-- Event-driven
| | |-- Native webhooks? NO — Sage Accounting has no webhooks
| | |-- Alternative --> Poll with updated_from/updated_to timestamps
| |-- File-based (CSV/XML)
| |-- NOT SUPPORTED via API — use Sage's built-in CSV import UI
|-- Which direction?
| |-- Inbound --> max 2,500 writes/day per company
| |-- Outbound --> max 2,500 reads/day per company
| |-- Bidirectional --> budget 1,250 each direction per day
|-- Token management
|-- Access token expires every 5 minutes
|-- Refresh proactively before each batch
|-- Serialize refresh token operations (no parallel refresh)
Quick Reference
| Operation | Method | Endpoint | Payload | Notes |
|---|---|---|---|---|
| List contacts | GET | /contacts?items_per_page=200 | N/A | Paginated; use page parameter |
| Create contact | POST | /contacts | JSON | Single record per request |
| Get sales invoice | GET | /sales_invoices/{id} | N/A | Returns full invoice with line items |
| Create sales invoice | POST | /sales_invoices | JSON | Include invoice_lines array |
| List bank accounts | GET | /bank_accounts | N/A | Returns all bank accounts |
| Create journal | POST | /journals | JSON | Double-entry: debit + credit lines required |
| List ledger accounts | GET | /ledger_accounts | N/A | Chart of accounts |
| Get businesses | GET | /businesses | N/A | Returns businesses for authenticated user |
| List tax rates | GET | /tax_rates | N/A | Country-specific tax rates |
| Get financial settings | GET | /financial_settings | N/A | Fiscal year, base currency, etc. |
Step-by-Step Integration Guide
1. Register your application
Register at the Sage Developer Portal to obtain a Client ID and Client Secret. Specify your redirect URI exactly. [src2]
# Record these values after registration:
# - Client ID: {your-client-id}
# - Client Secret: {your-client-secret}
# - Redirect URI: https://yourapp.example.com/sage/callback
# - Subscription Key: {your-subscription-key}
Verify: Your application appears in the Sage Developer Portal dashboard with an active Client ID.
2. Authorize the user via OAuth 2.0
Redirect the user to Sage's authorization endpoint. After the user grants permission, Sage redirects back with an authorization code. [src2]
import urllib.parse
client_id = "YOUR_CLIENT_ID"
redirect_uri = "https://yourapp.example.com/sage/callback"
auth_url = (
"https://www.sageone.com/oauth2/auth/central"
f"?response_type=code"
f"&client_id={client_id}"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
f"&scope=full_access"
)
print(f"Redirect user to: {auth_url}")
Verify: User is redirected back to your redirect URI with ?code={authorization_code} in the query string.
3. Exchange authorization code for tokens
POST the authorization code to the token endpoint to receive an access token and refresh token. [src2, src5]
import requests
token_url = "https://oauth.accounting.sage.com/token"
response = requests.post(token_url, data={
"grant_type": "authorization_code",
"code": authorization_code,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"redirect_uri": "https://yourapp.example.com/sage/callback"
})
tokens = response.json()
access_token = tokens["access_token"] # Valid for ~5 minutes
refresh_token = tokens["refresh_token"] # Valid for 31 days, rotates on use
Verify: Response contains access_token, refresh_token, and resource_owner_id fields.
4. Discover and select business
After authentication, discover available businesses and store the selected business ID. [src5, src7]
base_url = "https://api.accounting.sage.com/v3.1"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
response = requests.get(f"{base_url}/businesses", headers=headers)
businesses = response.json()
selected_business_id = businesses["$items"][0]["id"]
Verify: GET /businesses returns at least one business with id, name, and country fields.
5. Make API calls with proper headers
Include the access token and business ID in all subsequent API requests. [src1, src5]
headers = {
"Authorization": f"Bearer {access_token}",
"X-Business": selected_business_id,
"Content-Type": "application/json",
"Accept": "application/json"
}
response = requests.get(
f"{base_url}/contacts?items_per_page=200&page=1",
headers=headers
)
data = response.json()
contacts = data["$items"]
total = data["$total"]
Verify: Response contains $items array with contact records and $total count.
6. Implement token refresh
Proactively refresh the access token before it expires (every 5 minutes). Store the new refresh token immediately. [src5, src7]
def refresh_access_token(current_refresh_token):
response = requests.post(token_url, data={
"grant_type": "refresh_token",
"refresh_token": current_refresh_token,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET"
})
if response.status_code == 200:
tokens = response.json()
# CRITICAL: Store the new refresh token immediately
# The old refresh token is now permanently invalid
new_access_token = tokens["access_token"]
new_refresh_token = tokens["refresh_token"]
return new_access_token, new_refresh_token
else:
raise Exception(f"Token refresh failed: {response.json()}")
Verify: New access token works with GET /businesses. New refresh token is different from the previous one.
Code Examples
Python: List all sales invoices with pagination
# Input: access_token, business_id, base_url
# Output: All sales invoices across all pages
import requests
import time
def get_all_invoices(access_token, business_id, base_url):
headers = {
"Authorization": f"Bearer {access_token}",
"X-Business": business_id,
"Accept": "application/json"
}
all_invoices = []
page = 1
while True:
response = requests.get(
f"{base_url}/sales_invoices",
headers=headers,
params={"items_per_page": 200, "page": page}
)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
time.sleep(retry_after)
continue
data = response.json()
all_invoices.extend(data.get("$items", []))
if data.get("$next"):
page += 1
time.sleep(0.6) # Respect 100/min rate limit
else:
break
return all_invoices
JavaScript/Node.js: Create a sales invoice
// Input: accessToken, businessId, invoice data
// Output: Created invoice object with Sage-assigned ID
const fetch = require("node-fetch");
const BASE_URL = "https://api.accounting.sage.com/v3.1";
async function createSalesInvoice(accessToken, businessId, invoiceData) {
const response = await fetch(`${BASE_URL}/sales_invoices`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"X-Business": businessId,
"Content-Type": "application/json",
},
body: JSON.stringify({
sales_invoice: {
contact_id: invoiceData.contactId,
date: invoiceData.date,
due_date: invoiceData.dueDate,
invoice_lines: invoiceData.lines.map((line) => ({
description: line.description,
ledger_account_id: line.ledgerAccountId,
quantity: line.quantity,
unit_price: line.unitPrice,
tax_rate_id: line.taxRateId,
})),
},
}),
});
if (!response.ok) throw new Error(`Sage API error: ${response.status}`);
return response.json();
}
cURL: Quick API test
# Input: access_token, business_id
# Output: List of contacts from Sage Accounting
# Get businesses
curl -s "https://api.accounting.sage.com/v3.1/businesses" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Accept: application/json" | jq '.["$items"][] | {id, name}'
# List contacts
curl -s "https://api.accounting.sage.com/v3.1/contacts?items_per_page=10" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-Business: YOUR_BUSINESS_ID" \
-H "Accept: application/json" | jq '.["$items"][] | {id, name}'
Data Mapping
Field Mapping Reference
| Sage API Field | Common ERP Equivalent | Type | Transform | Gotcha |
|---|---|---|---|---|
contact.name | Customer/Vendor name | String (max 200) | Direct | Must be unique per contact type |
sales_invoice.date | Invoice date | String (ISO 8601) | YYYY-MM-DD format | No time component — date only |
sales_invoice.total_amount | Invoice total | Decimal | Read-only | Computed from line items — cannot set directly |
ledger_account.nominal_code | Account code/number | String | Country-specific format | UK uses 4-digit codes; US differs |
tax_rate.percentage | Tax rate | Decimal | Direct | Country-specific; UK VAT vs US sales tax |
bank_account.balance | Bank balance | Decimal | Read-only | Updated by bank transactions only |
contact.currency_id | Default currency | String (ISO 4217) | GBP, USD, EUR | Set at creation; changing requires care |
payment.amount | Payment amount | Decimal | Direct | Must match allocated invoice amounts |
Data Type Gotchas
- Dates are date-only: All date fields use YYYY-MM-DD format. No time component. For audit trails, use
created_atandupdated_at. [src1] - Country-specific fields appear/disappear: The same endpoint returns different fields depending on the business's country. UK has VAT fields; US has sales tax fields. [src1, src7]
- IDs are opaque strings: Sage uses long opaque string IDs (not sequential integers). Never attempt to parse or predict ID formats. [src1]
Error Handling & Failure Points
Common Error Codes
| Code | Meaning | Cause | Resolution |
|---|---|---|---|
| 401 | Unauthorized | Access token expired (>5 min) or invalid | Refresh the access token and retry |
| 403 | Forbidden | Insufficient permissions or wrong business | Verify X-Business header and user permissions |
| 404 | Not Found | Record ID does not exist or wrong API version | Check endpoint path includes /v3.1/ and record ID is valid |
| 409 | Conflict | Duplicate record or concurrent modification | Check for existing record; implement optimistic locking |
| 422 | Unprocessable Entity | Validation error — missing/invalid fields | Read error message body for specific field errors |
| 429 | Too Many Requests | Rate limit exceeded (100/min or 2,500/day) | Implement exponential backoff; respect Retry-After header |
| 500 | Internal Server Error | Sage platform issue | Retry with backoff; report to Sage if persistent |
Failure Points in Production
- Refresh token loss causes permanent disconnection: If your database loses the refresh token or you use it twice in a race condition, the user must re-authorize interactively. Fix:
Store refresh tokens in a transactional database. Use a mutex around the refresh operation.[src5, src7] - 5-minute access token expiry mid-batch: A batch processing 200 records at 1/second takes 3+ minutes. The token expires before completion. Fix:
Check token age before each request. Refresh proactively at 4 minutes.[src5] - Daily limit exhaustion stops all integrations: The 2,500/day limit is per-company, shared across ALL apps. Fix:
Monitor daily usage. Cache read-heavy data locally. Use updated_from filters.[src6] - Country-specific validation rejects records: Creating a UK invoice requires VAT fields that US invoices do not have. Fix:
Query the business's country from GET /businesses and branch logic accordingly.[src1] - Pagination inconsistency during concurrent writes: Records created between page requests may be missed or duplicated. Fix:
Use updated_from/updated_to filters instead of page-based pagination for sync.[src3]
Anti-Patterns
Wrong: Caching access tokens for 1 hour
# BAD — Sage v3.1 tokens expire in 5 minutes, not 1 hour
token = get_token()
cache.set("sage_token", token, ttl=3600) # 1 hour cache — WRONG
Correct: Refresh proactively before each batch
# GOOD — check token age, refresh if >4 minutes old
import time
token_acquired_at = time.time()
def get_valid_token():
global token_acquired_at, access_token, refresh_token
if time.time() - token_acquired_at > 240: # 4 min threshold
access_token, refresh_token = refresh_access_token(refresh_token)
token_acquired_at = time.time()
return access_token
Wrong: Parallel refresh token operations
# BAD — two threads use the SAME refresh token — race condition
import threading
def worker(refresh_token):
new_access = refresh_access_token(refresh_token) # Race!
make_api_calls(new_access)
t1 = threading.Thread(target=worker, args=(current_refresh,))
t2 = threading.Thread(target=worker, args=(current_refresh,))
t1.start(); t2.start()
Correct: Serialize refresh operations with a lock
# GOOD — use a mutex to ensure only one refresh at a time
import threading
token_lock = threading.Lock()
def get_fresh_token():
with token_lock:
if needs_refresh():
return refresh_access_token(current_refresh_token)
return current_access_token
Wrong: Fetching all records to detect changes
# BAD — downloads ALL contacts every sync, wastes API quota
all_contacts = get_all_contacts() # Could be 500+ API calls
for contact in all_contacts:
if contact["updated_at"] > last_sync:
process(contact)
Correct: Use updated_from filter for incremental sync
# GOOD — only fetch records changed since last sync
changed_contacts = requests.get(
f"{base_url}/contacts",
headers=headers,
params={
"updated_from": last_sync_timestamp,
"items_per_page": 200
}
)
Common Pitfalls
- Assuming v3 token lifetimes apply to v3.1: The v3 API had 60-minute access tokens. The v3.1 API reduced this to 5 minutes. Many blog posts reference the old 1-hour lifetime. Fix:
Always assume 5-minute token lifetime. Refresh at 4 minutes.[src5, src7] - Not handling X-Business header for multi-business users: Users with multiple businesses will get data from their "lead" business by default. Fix:
Always call GET /businesses after OAuth and include X-Business header on every request.[src5] - Ignoring country-specific field differences: Same endpoint returns different fields for UK (VAT), US (sales tax), France (TVA). Fix:
Check the business's country and conditionally include required fields.[src1] - Not implementing rate limit backoff: The 100/min per-company limit is easy to hit during sync. Fix:
Implement exponential backoff starting at 1 second, doubling up to 60 seconds.[src6] - Treating the daily limit as per-app: The 2,500/day limit is per-company, shared across all apps. Fix:
Monitor quota consumption. Implement request budgeting.[src6] - Not storing refresh tokens durably: Refresh tokens are single-use. If your app crashes between receiving a new token and storing it, the integration disconnects permanently. Fix:
Write the new refresh token to durable storage in the same transaction.[src5]
Diagnostic Commands
# Test authentication — exchange code for tokens
curl -s -X POST "https://oauth.accounting.sage.com/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=YOUR_AUTH_CODE&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&redirect_uri=YOUR_REDIRECT_URI" | jq .
# Refresh access token
curl -s -X POST "https://oauth.accounting.sage.com/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET" | jq .
# List available businesses
curl -s "https://api.accounting.sage.com/v3.1/businesses" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" | jq '.["$items"][] | {id, name, country}'
# Check API connectivity
curl -s "https://api.accounting.sage.com/v3.1/me" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-Business: YOUR_BUSINESS_ID" | jq .
# List ledger accounts
curl -s "https://api.accounting.sage.com/v3.1/ledger_accounts?items_per_page=5" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-Business: YOUR_BUSINESS_ID" | jq '.["$items"][] | {id, nominal_code, name}'
Version History & Compatibility
| API Version | Status | Key Changes | Migration Notes |
|---|---|---|---|
| v3.1 | Current (GA) | 5-min access tokens, rotating refresh tokens, X-Business header replaces X-Site, unified multi-country support | Must update token management logic; header name change; refresh token rotation requires serialized storage |
| v3 | Deprecated | 60-min access tokens, non-rotating refresh tokens, X-Site header | Previous stable version — sunset timeline not publicly announced |
| v2 | EOL | Legacy authentication model | Fully decommissioned; no migration path except full rewrite to v3.1 |
| v1 | EOL | Original Sage One API | Fully decommissioned |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| SMB accounting integration with <2,500 ops/day | High-volume enterprise integration (>2,500 records/day) | Sage Intacct (100K+ API transactions/month) |
| Building SaaS integration with Sage Accounting customers | Need S2S daemon/batch without user involvement | Sage Intacct or Xero API (supports client credentials) |
| Multi-country accounting (UK, US, CA, FR, ES, DE, AU) | Need real-time event notifications (webhooks) | Xero API (native webhooks) or QuickBooks API |
| Low-volume sync of accounting data | Need bulk data migration (>10K records) | Manual Sage CSV import or Sage Intacct |
| Read-only reporting and dashboards | Complex multi-entity financial consolidation | Sage Intacct (multi-entity natively) |
Important Caveats
- Sage Business Cloud Accounting and Sage Intacct are completely separate products with different APIs, authentication, and rate limits. Sage Accounting is for SMBs; Sage Intacct is enterprise-grade.
- The 2,500 requests/day per-company limit is very restrictive compared to competitors (QuickBooks: 500/min, Xero: 60/min). Plan your integration architecture around this constraint from day one.
- Access token lifetime (5 minutes) and refresh token rotation are the most aggressive token management requirements among major accounting APIs. Your token management code will be more complex than the actual business logic.
- Country-specific behavior means you cannot build a single integration path — you must handle UK VAT, US sales tax, French TVA, and other regional variations.
- Rate limits documented here are from third-party sources and Sage developer community resources. Always verify against your specific Sage developer agreement and current API documentation.