---
# === IDENTITY ===
id: business/erp-integration/sage-business-cloud-api/2026
canonical_question: "What are the Sage Business Cloud API capabilities and integration patterns?"
aliases:
  - "Sage Accounting API v3.1 rate limits and authentication"
  - "Sage Business Cloud REST API endpoints and OAuth 2.0 flow"
  - "How to integrate with Sage Business Cloud Accounting API"
  - "Sage Accounting API token management and refresh token rotation"
entity_type: erp_integration
domain: business > erp-integration > sage-business-cloud-api
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === SYSTEM PROFILE ===
systems:
  - name: "Sage Business Cloud Accounting"
    vendor: "Sage"
    version: "API v3.1"
    edition: "Sage Accounting (all tiers)"
    deployment: "cloud"
    api_surface: "REST"

# === VERIFICATION ===
last_verified: 2026-03-02
confidence: 0.88
version: 1.0
first_published: 2026-03-02

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: volatile
  last_breaking_change: "v3.1 — access token lifetime reduced to 5 minutes, refresh tokens now expire after 31 days, X-Site header renamed to X-Business"
  next_review: 2026-08-29
  change_sensitivity: high

# === CONSTRAINTS ===
constraints:
  - "Access tokens expire after only 5 minutes (reduced from 60 minutes in v3)"
  - "Refresh tokens expire after 31 days and rotate on every use — previous refresh token is invalidated"
  - "Rate limit: 100 requests per minute per company, 2,500 requests per day per company"
  - "App-level limits: 1,296,000 requests per day and 150 concurrent requests per app"
  - "Maximum 200 records per page for list endpoints (default 20)"
  - "100 MB data transfer limit per 60-minute period"
  - "20 failed login attempts per hour before IP block (1-hour block duration)"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs Sage Intacct API (enterprise-grade, SOAP/XML-based)"
    use_instead: "business/erp-integration/sage-intacct-api/2026"
  - condition: "User needs Sage X3 API (GraphQL-based)"
    use_instead: "business/erp-integration/sage-x3-api/2026"
  - condition: "User needs Sage 50 desktop API"
    use_instead: "N/A — Sage 50 uses a local SDK, not a cloud REST API"

# === AGENT HINTS ===
inputs_needed:
  - key: integration_pattern
    question: "What integration pattern do you need?"
    type: choice
    options:
      - "real-time sync (individual records, <1s latency)"
      - "batch/bulk (scheduled, high volume)"
      - "event-driven (polling-based, no native webhooks)"
      - "file-based (CSV/XML import/export)"
  - key: data_volume
    question: "What's your daily data volume?"
    type: choice
    options:
      - "< 500 records/day"
      - "500-2,500 records/day"
      - "> 2,500 records/day (will hit daily per-company limit)"
  - key: direction
    question: "What's the data flow direction?"
    type: choice
    options:
      - "inbound (writing to Sage)"
      - "outbound (reading from Sage)"
      - "bidirectional sync"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/business/erp-integration/sage-business-cloud-api/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-02)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "business/erp-integration/sage-intacct-api/2026"
      label: "Sage Intacct API capabilities — enterprise-grade SOAP/REST alternative"
  solves:
    - id: "business/erp-integration/order-to-cash-integration/2026"
      label: "Order-to-cash integration playbook using accounting APIs"
  alternative_to:
    - id: "business/erp-integration/zoho-flow/2026"
      label: "Zoho Books API — competing SMB cloud accounting API"
  often_confused_with:
    - id: "business/erp-integration/sage-intacct-api/2026"
      label: "Sage Intacct — separate product with different API (SOAP/XML), not the same as Sage Business Cloud Accounting"

# === SOURCES ===
sources:
  - id: src1
    title: "Sage Accounting API Overview — Developer Portal"
    author: Sage
    url: https://developer.sage.com/accounting/guides/concepts/overview
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src2
    title: "Sage Accounting API Authentication Guide"
    author: Sage
    url: https://developer.sage.com/accounting/guides/authenticating/authentication
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src3
    title: "Sage Accounting API Pagination"
    author: Sage
    url: https://developer.sage.com/accounting/guides/efficiencies/pagination/
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src4
    title: "Sage Accounting API Best Practices"
    author: Sage
    url: https://developer.sage.com/api/accounting/guides/best_practices
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src5
    title: "How To Integrate Sage Business Cloud Accounting API"
    author: Apideck
    url: https://www.apideck.com/blog/how-to-integrate-sage-business-cloud-accounting-api
    type: technical_blog
    published: 2025-03-15
    reliability: moderate_high
  - id: src6
    title: "Sage Business Cloud API Essentials"
    author: Rollout
    url: https://rollout.com/integration-guides/sage-business-cloud/api-essentials
    type: technical_blog
    published: 2025-04-01
    reliability: moderate_high
  - id: src7
    title: "Migrate from v3 to v3.1 — Sage Developer"
    author: Sage
    url: https://developer.sage.com/accounting/guides/migrating/migrate-from-v3-to-v3-1/
    type: official_docs
    published: 2025-01-01
    reliability: authoritative
---

# Sage Business Cloud Accounting API: Capabilities, Rate Limits, 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. [src6]
- **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. [src5, src7]
- **Best for**: SMB accounting integrations (invoicing, contacts, bank reconciliation) with low-to-medium data volumes (<2,500 operations/day per company). [src1]
- **Authentication**: OAuth 2.0 authorization code flow. No client credentials (S2S) flow — every integration requires initial user authorization. Access token: 5 min, refresh token: 31 days (rotating). [src2, src5]

## System Profile

Sage Business Cloud Accounting (formerly Sage One, then Sage Business Cloud) 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 is a single unified REST surface serving 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). [src1, src5]

| 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 URL** | [Sage Accounting Developer Portal](https://developer.sage.com/accounting) |
| **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. [src1, src6]

| 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 include: [src1, src5]

| 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`, `/bank_deposits` | GET, POST, PUT, DELETE |
| Ledger | `/ledger_accounts`, `/ledger_entries`, `/journals` | GET, POST, PUT, DELETE |
| Products & Services | `/products`, `/services` | GET, POST, PUT, DELETE |
| Tax | `/tax_rates`, `/tax_types`, `/tax_schemes` | 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 [src3] |
| Max request body size | Not documented (practical ~1 MB) | POST/PUT | Single record per request — no batch endpoint |
| Data transfer limit | 100 MB | Per app | Rolling 60-minute window [src6] |
| Max concurrent requests | 150 | Per app (all companies) | Across all companies your app serves [src6] |

[src3, src6]

### Rolling / Daily Limits

| Limit Type | Value | Window | Notes |
|---|---|---|---|
| API requests per company | 2,500 | 24h rolling | Per-company, not per-app. Shared across all apps accessing the same company [src6] |
| API requests per minute | 100 | 1 minute | Per-company rate limit [src6] |
| API requests per app | 1,296,000 | 24h | App-level aggregate across all companies [src6] |
| Failed login attempts | 20 | 1 hour | IP blocked for 1 hour after exceeding [src6] |

[src6]

## 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. After authorization, the integration operates via access + refresh token pairs. [src2, src5]

| 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

1. **Authorization**: Redirect user to `https://www.sageone.com/oauth2/auth/central?response_type=code&client_id={id}&redirect_uri={uri}&scope=full_access` [src2]
2. **Token exchange**: POST to `https://oauth.accounting.sage.com/token` with `grant_type=authorization_code` [src2]
3. **Access**: Include token as `Authorization: Bearer {token}` header. Token valid for 5 minutes. [src5, src7]
4. **Refresh**: POST to token endpoint with `grant_type=refresh_token`. Returns new access token AND new refresh token. Previous refresh token is invalidated. [src5, src7]
5. **Business selection**: After auth, call `GET /businesses` to discover available businesses, then include `X-Business: {business_id}` header on all subsequent requests. [src5, src7]

### Authentication Gotchas

- **5-minute access tokens are aggressively short**: You must implement proactive token refresh before every batch of API calls. Caching the token for even 10 minutes will fail. [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 (e.g., race condition in distributed systems), the second call fails and both tokens are invalidated. You must 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. There is no automated recovery. Plan scheduled keep-alive refreshes. [src5, src7]
- **X-Business header is required for multi-business users**: After OAuth, you cannot determine business access from the token itself. You must call `GET /businesses` and present a selection to the user, then store the selected business ID permanently. [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_from` and `updated_to` filters. This limits real-time integration patterns. [src1, src6]
- **2,500 requests/day per company is extremely low** compared to enterprise ERPs. A full data sync of contacts + invoices + payments can exhaust the daily limit in a single run. [src6]
- **No bulk/batch API** — every record must be created, updated, or deleted individually. There is no composite or batch endpoint. [src1]
- **Single record per write operation** — POST/PUT endpoints accept one record per request, so creating 100 invoices requires 100 API calls. [src1]
- **No S2S/daemon authentication flow** — all integrations require initial interactive user authorization and periodic re-authorization if refresh tokens expire. [src2]
- **Tax and regulatory fields vary by country** — the same endpoint returns different fields depending on the business's country. Tax codes, VAT/GST handling, and regulatory fields are region-specific. [src1, src7]

## 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 available)
|   |       |-- 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)
|   |   |       |-- Spread operations across multiple days
|   |   |       |-- Contact Sage for enterprise limits
|   |   |-- Need atomic multi-record operations?
|   |       |-- NO --> Sequential REST API calls with error tracking
|   |       |-- YES --> STOP — no batch/transaction API available
|   |-- Event-driven
|   |   |-- Native webhooks?
|   |   |   |-- NO --> Sage Accounting has no webhooks
|   |   |-- Alternative --> Poll with updated_from/updated_to timestamps
|   |   |   |-- Poll interval: respect 100/min rate limit
|   |   |   |-- Track last_synced timestamp per entity type
|   |-- File-based (CSV/XML)
|       |-- NOT SUPPORTED via API — Sage Accounting has no file import API
|       |-- Use Sage's built-in CSV import UI for initial data loads
|-- Which direction?
|   |-- Inbound (writing to Sage) --> max 2,500 writes/day per company
|   |-- Outbound (reading from Sage) --> 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)
    |-- Schedule keep-alive refresh every <31 days
```

## Quick Reference

| Operation | Method | Endpoint | Payload | Notes |
|---|---|---|---|---|
| List contacts | GET | `/contacts?items_per_page=200` | N/A | Paginated; use `page` parameter [src1] |
| Create contact | POST | `/contacts` | JSON | Single record per request [src1] |
| Get sales invoice | GET | `/sales_invoices/{id}` | N/A | Returns full invoice with line items [src1] |
| Create sales invoice | POST | `/sales_invoices` | JSON | Include `invoice_lines` array [src1] |
| List bank accounts | GET | `/bank_accounts` | N/A | Returns all bank accounts [src1] |
| Create journal | POST | `/journals` | JSON | Double-entry: debit + credit lines required [src1] |
| List ledger accounts | GET | `/ledger_accounts` | N/A | Chart of accounts [src1] |
| Get businesses | GET | `/businesses` | N/A | Returns businesses for authenticated user [src5] |
| List tax rates | GET | `/tax_rates` | N/A | Country-specific tax rates [src1] |
| Get financial settings | GET | `/financial_settings` | N/A | Fiscal year, base currency, etc. [src1] |

## Step-by-Step Integration Guide

### 1. Register your application

Register at the Sage Developer Portal (https://developer.sage.com) to obtain a Client ID and Client Secret. Specify your redirect URI exactly — it must match character-for-character in your authorization requests. [src2]

```bash
# 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} (required for API calls)
```

**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 to your redirect URI with an authorization code. [src2]

```python
# Input:  client_id, redirect_uri
# Output: Authorization URL to redirect user to

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]

```python
# Input:  authorization_code, client_id, client_secret, redirect_uri
# Output: access_token (5 min), refresh_token (31 days)

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
resource_owner_id = tokens["resource_owner_id"]  # Needed for X-Site/X-Business
```

**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]

```python
# Input:  access_token
# Output: List of businesses, selected business_id

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()

# Store the selected business ID for all future requests
selected_business_id = businesses["$items"][0]["id"]
print(f"Business: {businesses['$items'][0]['name']} (ID: {selected_business_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]

```python
# Input:  access_token, business_id
# Output: List of contacts from the selected business

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"]
print(f"Retrieved {len(contacts)} of {total} contacts")
```

**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]

```python
# Input:  refresh_token, client_id, client_secret
# Output: new access_token, new refresh_token

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

```python
# 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):
    """Fetch all sales invoices with pagination and rate limiting."""
    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:
            # Rate limited — wait and retry
            retry_after = int(response.headers.get("Retry-After", 60))
            time.sleep(retry_after)
            continue

        if response.status_code != 200:
            raise Exception(f"API error {response.status_code}: {response.text}")

        data = response.json()
        invoices = data.get("$items", [])
        all_invoices.extend(invoices)

        # Check if more pages exist
        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

```javascript
// Input:  accessToken, businessId, invoice data
// Output: Created invoice object with Sage-assigned ID

// npm install node-fetch@2
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",
      Accept: "application/json",
    },
    body: JSON.stringify({
      sales_invoice: {
        contact_id: invoiceData.contactId,
        date: invoiceData.date,           // "2026-03-02"
        due_date: invoiceData.dueDate,     // "2026-04-01"
        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.status === 429) {
    const retryAfter = response.headers.get("Retry-After") || 60;
    throw new Error(`Rate limited. Retry after ${retryAfter}s`);
  }

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Sage API error: ${JSON.stringify(error)}`);
  }

  return response.json();
}
```

### cURL: Quick API test

```bash
# Input:  access_token, business_id
# Output: List of contacts from Sage Accounting

# Step 1: Get businesses (to find business_id)
curl -s "https://api.accounting.sage.com/v3.1/businesses" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Accept: application/json" | jq '.["$items"][] | {id, name}'

# Step 2: List contacts for a specific business
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, email}'

# Step 3: Create a contact
curl -s -X POST "https://api.accounting.sage.com/v3.1/contacts" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "X-Business: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "contact": {
      "name": "Test Customer Ltd",
      "contact_type_ids": ["CUSTOMER"],
      "main_address": {
        "address_line_1": "123 Test Street",
        "city": "London",
        "postal_code": "EC1A 1BB"
      }
    }
  }' | jq .

# Expected: JSON object with "id", "name", "created_at" fields
```

## 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 [src1] |
| `sales_invoice.date` | Invoice date | String (ISO 8601) | `YYYY-MM-DD` format | No time component — date only [src1] |
| `sales_invoice.total_amount` | Invoice total | Decimal | Read-only; calculated from lines | Cannot be set directly — computed from line items [src1] |
| `ledger_account.nominal_code` | Account code/number | String | Country-specific format | UK uses 4-digit codes; US uses different scheme [src1] |
| `tax_rate.percentage` | Tax rate | Decimal | Direct | Country-specific; UK VAT rates differ from US sales tax [src1] |
| `bank_account.balance` | Bank balance | Decimal | Read-only | Updated by bank transactions, not directly writable [src1] |
| `contact.currency_id` | Default currency | String (ISO 4217) | `GBP`, `USD`, `EUR` | Set at contact creation; changing requires care [src1] |
| `payment.amount` | Payment amount | Decimal | Direct | Must match allocated invoice amounts or be unallocated [src1] |

### Data Type Gotchas

- **Dates are date-only (no timestamp)**: All date fields use `YYYY-MM-DD` format. There is no time component. For audit trails, use `created_at` and `updated_at` which include full ISO 8601 timestamps. [src1]
- **Currency amounts have no explicit precision field**: The API returns decimal amounts but does not specify precision. Always use the business's base currency decimal places (e.g., 2 for GBP/USD, 0 for JPY). [src1]
- **Country-specific fields appear/disappear**: The same endpoint returns different fields depending on the business's country. UK businesses have VAT fields; US businesses have sales tax fields. Do not assume field presence. [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 [src2] |
| 403 | Forbidden | Insufficient permissions or wrong business | Verify X-Business header and user permissions [src5] |
| 404 | Not Found | Record ID does not exist or wrong API version | Check endpoint path includes `/v3.1/` and record ID is valid [src1] |
| 409 | Conflict | Duplicate record or concurrent modification | Check for existing record; implement optimistic locking [src1] |
| 422 | Unprocessable Entity | Validation error — missing/invalid fields | Read error message body for specific field errors [src1] |
| 429 | Too Many Requests | Rate limit exceeded (100/min or 2,500/day) | Implement exponential backoff; respect Retry-After header [src6] |
| 500 | Internal Server Error | Sage platform issue | Retry with backoff; report to Sage if persistent [src4] |

[src1, src4, src6]

### Failure Points in Production

- **Refresh token loss causes permanent disconnection**: If your database loses the refresh token (crash, migration error), or you use it twice in a race condition, the user must re-authorize interactively. There is no API endpoint to recover. Fix: `Store refresh tokens in a transactional database. Use a mutex/lock around the refresh operation. Implement a scheduled keep-alive refresh every 7 days.` [src5, src7]
- **5-minute access token expiry mid-batch**: A batch processing 200 records at 1/second takes 3+ minutes. The token expires before the batch completes. Fix: `Check token age before each request. Refresh proactively at 4 minutes. Never cache tokens for longer than 4 minutes.` [src5, src7]
- **Daily limit exhaustion stops all integrations**: The 2,500/day limit is per-company, shared across ALL apps. If another integration consumes the quota, yours fails. Fix: `Monitor daily usage. Implement request budgeting. Cache read-heavy data locally. Use updated_from filters to minimize unnecessary reads.` [src6]
- **Country-specific validation rejects records**: Creating a UK invoice requires VAT-specific fields that US invoices do not have. Fix: `Query the business's country from GET /businesses and branch your record creation logic accordingly.` [src1, src7]
- **Pagination returns inconsistent data during concurrent writes**: If records are created between page requests, you may miss or duplicate records. Fix: `Use updated_from/updated_to filters instead of page-based pagination for sync operations. Accept eventual consistency.` [src3]

## Anti-Patterns

### Wrong: Caching access tokens for the full "1 hour"

```python
# BAD — Sage v3.1 tokens expire in 5 minutes, not 1 hour
# Outdated documentation or v3 behavior will mislead you
token = get_token()
cache.set("sage_token", token, ttl=3600)  # 1 hour cache — WRONG
```

### Correct: Refresh proactively before each batch

```python
# 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

```python
# BAD — two threads refresh simultaneously, one gets invalidated
import threading

def worker(refresh_token):
    # Both threads use the SAME refresh token
    new_access = refresh_access_token(refresh_token)  # Race condition!
    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

```python
# 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:
        # Only one thread refreshes at a time
        if needs_refresh():
            return refresh_access_token(current_refresh_token)
        return current_access_token
```

### Wrong: Fetching all records to detect changes

```python
# BAD — downloads ALL contacts every sync cycle, 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

```python
# GOOD — only fetch records changed since last sync
changed_contacts = requests.get(
    f"{base_url}/contacts",
    headers=headers,
    params={
        "updated_from": last_sync_timestamp,  # ISO 8601
        "items_per_page": 200
    }
)
# Processes only changed records, saving API quota
```

## 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 and Stack Overflow answers reference the old 1-hour lifetime. Fix: `Always assume 5-minute token lifetime. Refresh at 4 minutes.` [src5, src7]
- **Not handling the X-Business header for multi-business users**: Users with access to multiple Sage businesses will get data from their "lead" business by default, which may not be the intended one. Fix: `Always call GET /businesses after OAuth, present a picker to the user, and include the selected X-Business header on every request.` [src5, src7]
- **Ignoring country-specific field differences**: The same `sales_invoice` endpoint returns different fields for UK (VAT registration number, VAT amount) vs US (sales tax) vs France (TVA). Fix: `Check the business's country from financial_settings and conditionally include required country-specific fields.` [src1, src7]
- **Not implementing rate limit backoff**: The 100/min per-company limit is easy to hit during sync operations. Sage returns 429 without a guaranteed Retry-After header. Fix: `Implement exponential backoff starting at 1 second, doubling up to 60 seconds. Limit to 5 retries, then fail the operation.` [src6]
- **Treating the daily limit as per-app**: The 2,500/day limit is per-company, shared across all apps. If the customer uses another Sage integration, your quota is reduced. Fix: `Monitor quota consumption. Implement request budgeting and prioritization. Cache aggressively.` [src6]
- **Not storing refresh tokens durably**: Refresh tokens are single-use. If your app crashes between receiving a new refresh token and storing it, the integration is permanently disconnected. Fix: `Write the new refresh token to durable storage in the same transaction as the API response processing.` [src5]

## Diagnostic Commands

```bash
# 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" \
  -d "code=YOUR_AUTH_CODE" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "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" \
  -d "refresh_token=YOUR_REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "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 and verify access
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 (chart of accounts structure)
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 [src7] |
| v3 | Deprecated | 60-min access tokens, non-rotating refresh tokens, X-Site header | Previous stable version — sunset timeline not publicly announced [src7] |
| v2 | EOL | Legacy authentication model | Fully decommissioned. No migration path except full rewrite to v3.1 [src7] |
| v1 | EOL | Original Sage One API | Fully decommissioned [src7] |

[src7]

### Deprecation Policy

Sage provides migration guides between API versions but does not publish a formal deprecation timeline with specific sunset dates. The v3.1 migration introduced breaking changes (token lifetime, header rename, refresh token rotation) without a fixed deadline for v3 shutdown. Developers should monitor the Sage Developer Portal changelog and migrate proactively. [src7]

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| SMB accounting integration (invoicing, contacts, payments) with <2,500 ops/day | High-volume enterprise integration (>2,500 records/day) | Sage Intacct (100K+ API transactions/month) |
| Building a SaaS integration with Sage Accounting customers | Need server-to-server daemon/batch processing without user involvement | Consider Sage Intacct or Xero API (supports OAuth 2.0 client credentials) |
| Multi-country accounting (UK, US, CA, FR, ES, DE, AU) with unified API | Need real-time event notifications (webhooks) | Xero API (native webhooks) or QuickBooks API (webhooks available) |
| Low-volume sync of accounting data to/from your application | Need bulk data migration (>10K records) | Manual Sage CSV import or Sage Intacct Bulk API |
| Read-only reporting and dashboards pulling accounting data | Need 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. Do not confuse them — 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.
- The Sage Developer Portal documentation can be inconsistent between the reference docs and the conceptual guides. Always test against the actual API to confirm behavior. Rate limits documented here are from third-party sources and should be verified against your specific Sage developer agreement.

## Related Units

- [Sage Intacct API capabilities — enterprise SOAP/REST](/business/erp-integration/sage-intacct-api/2026)
- [Order-to-cash integration playbook](/business/erp-integration/order-to-cash-integration/2026)
- [ERP REST API comparison across vendors](/business/erp-integration/erp-rest-api-comparison/2026)
- [ERP rate limits comparison](/business/erp-integration/erp-rate-limits-comparison/2026)
