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 |
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 |
| 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 |
| 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 |
| 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 |
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] |
response_type=codehttps://oauth.accounting.sage.com/token with grant_type=authorization_codeAuthorization: Bearer {token} header. Token valid for 5 minutes.grant_type=refresh_token. Returns new access token AND new refresh token. Previous refresh token is invalidated.GET /businesses to discover available businesses, then include X-Business: {business_id} header on all requests.GET /businesses and present a selection to the user. [src5, src7]updated_from and updated_to filters.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)
| 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. |
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.
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.
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.
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.
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.
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.
# 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
// 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();
}
# 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}'
| 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 |
created_at and updated_at. [src1]| 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 |
Store refresh tokens in a transactional database. Use a mutex around the refresh operation. [src5, src7]Check token age before each request. Refresh proactively at 4 minutes. [src5]Monitor daily usage. Cache read-heavy data locally. Use updated_from filters. [src6]Query the business's country from GET /businesses and branch logic accordingly. [src1]Use updated_from/updated_to filters instead of page-based pagination for sync. [src3]# 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
# 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
# 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()
# 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
# 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)
# 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
}
)
Always assume 5-minute token lifetime. Refresh at 4 minutes. [src5, src7]Always call GET /businesses after OAuth and include X-Business header on every request. [src5]Check the business's country and conditionally include required fields. [src1]Implement exponential backoff starting at 1 second, doubling up to 60 seconds. [src6]Monitor quota consumption. Implement request budgeting. [src6]Write the new refresh token to durable storage in the same transaction. [src5]# 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}'
| 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 |
| 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) |