How to Connect Sage with Next.js

By Codefacture7 min read

How to Connect Sage with Next.js?

 

Sage Accounting is a cloud accounting platform used by small and medium-sized businesses in markets such as the United Kingdom, Ireland, the United States, and Canada. For applications built with Next.js, a Sage connection means invoices can be created automatically, customer records stay aligned, and financial data is available without manual exports. The Sage Accounting API v3.1 is a clean REST API, but it has a few operational details that catch developers out: access tokens that last only five minutes, refresh tokens that change on every use, and a header that decides which business each request writes to. In this guide, we'll connect Sage Accounting with the Next.js App Router step by step, with code examples for every stage. Note that this guide covers the cloud-based Sage Accounting product; desktop products such as Sage 50 and Sage 200 use different integration mechanisms.

 

Before You Start

Begin by registering an application in the Sage Developer portal. You'll receive a client ID and client secret, and you'll need to add your callback URL, for example http://localhost:3000/api/sage/callback during development. A trial or test business is strongly recommended for development, so you can create invoices freely without affecting real books.

This guide uses the native fetch API available in Next.js rather than a third-party SDK, which keeps dependencies minimal and gives you full control over token handling. Add your credentials to environment variables, keeping them without the NEXT_PUBLIC_ prefix so they are never exposed to the browser.

# .env.local
SAGE_CLIENT_ID=...
SAGE_CLIENT_SECRET=...
SAGE_REDIRECT_URI=http://localhost:3000/api/sage/callback

 

Implementing the OAuth 2.0 Flow

The authorization flow starts with a route handler that redirects the user to the Sage authorization page. A random state value is generated and stored in an HTTP-only cookie, so the callback can confirm the response belongs to the same user and protect against cross-site request forgery. The full_access scope allows reading and writing data; use readonly if your integration only needs to read.

// app/api/sage/connect/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";

export async function GET() {
  const state = crypto.randomUUID();
  (await cookies()).set("sage_oauth_state", state, {
    httpOnly: true,
    secure: true,
    maxAge: 600,
  });

  const url = new URL("https://www.sageone.com/oauth2/auth/central");
  url.searchParams.set("filter", "apiv3.1");
  url.searchParams.set("response_type", "code");
  url.searchParams.set("client_id", process.env.SAGE_CLIENT_ID!);
  url.searchParams.set("redirect_uri", process.env.SAGE_REDIRECT_URI!);
  url.searchParams.set("scope", "full_access");
  url.searchParams.set("state", state);

  return NextResponse.redirect(url);
}

When the user approves access, Sage redirects back to your callback route with an authorization code. The route verifies the state, exchanges the code for tokens at the Sage token endpoint, and stores the access token, refresh token, and expiry time in your database.

// app/api/sage/callback/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { saveSageTokens } from "@/lib/db";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const code = searchParams.get("code");
  const state = searchParams.get("state");
  const savedState = (await cookies()).get("sage_oauth_state")?.value;

  if (!code || !state || state !== savedState) {
    return new Response("Invalid OAuth state", { status: 400 });
  }

  const res = await fetch("https://oauth.accounting.sage.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.SAGE_REDIRECT_URI!,
      client_id: process.env.SAGE_CLIENT_ID!,
      client_secret: process.env.SAGE_CLIENT_SECRET!,
    }),
  });
  const tokens = await res.json();

  await saveSageTokens({
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  });

  return NextResponse.redirect(new URL("/settings/integrations", req.url));
}

 

Handling Short-Lived and Rotating Tokens

Sage access tokens expire after about five minutes, and each refresh returns a brand-new refresh token while invalidating the old one. Refresh tokens that aren't used within roughly a month also expire, forcing the user to reconnect. This makes token handling the most important part of a Sage integration. The helper below refreshes the token shortly before it expires, immediately stores the rotated refresh token, and wraps every API call with the required headers.

// lib/sage.ts
import "server-only";
import { getSageTokens, saveSageTokens } from "@/lib/db";

const API_BASE = "https://api.accounting.sage.com/v3.1";

async function getAccessToken() {
  const tokens = await getSageTokens();
  if (Date.now() < tokens.expiresAt - 60_000) return tokens.accessToken;

  const res = await fetch("https://oauth.accounting.sage.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: tokens.refreshToken,
      client_id: process.env.SAGE_CLIENT_ID!,
      client_secret: process.env.SAGE_CLIENT_SECRET!,
    }),
  });
  if (!res.ok) throw new Error("Sage re-authorization required");

  const fresh = await res.json();
  await saveSageTokens({
    ...tokens,
    accessToken: fresh.access_token,
    refreshToken: fresh.refresh_token, // refresh tokens rotate: always store the new one
    expiresAt: Date.now() + fresh.expires_in * 1000,
  });

  return fresh.access_token;
}

export async function sageFetch(path: string, init: RequestInit = {}) {
  const { businessId } = await getSageTokens();

  const res = await fetch(`${API_BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${await getAccessToken()}`,
      "Content-Type": "application/json",
      ...(businessId ? { "X-Business": businessId } : {}),
      ...init.headers,
    },
    cache: "no-store",
  });

  if (!res.ok) throw new Error(`Sage API error: ${res.status}`);
  return res.json();
}

Because refresh tokens rotate, two parallel requests that both try to refresh can invalidate each other, leaving the connection broken. In production, protect the refresh step with a lock, such as a database row lock or a distributed lock in Redis, so only one refresh runs at a time. For customers who use the integration rarely, a scheduled job that refreshes tokens periodically prevents them from expiring unused. Setting cache: "no-store" also ensures Next.js never caches responses containing financial data.

 

Selecting the Business and Making API Calls

A single Sage user can have access to several businesses, such as an accountant who manages the books for multiple clients. The OAuth flow authenticates the user but doesn't decide which business to use. After authorization, list the available businesses, let the user choose, and store the selected business ID. From then on, send it in the X-Business header with every request; if the header is omitted, Sage falls back to the user's lead business, which may not be the one you intend to write to.

// List the businesses the user can access, then store the chosen business id
const businesses = await sageFetch("/businesses");

// Create a sales invoice in the selected business
const invoice = await sageFetch("/sales_invoices", {
  method: "POST",
  body: JSON.stringify({
    sales_invoice: {
      contact_id: contactId,
      date: new Date().toISOString().slice(0, 10),
      invoice_lines: [
        {
          description: "Consulting services",
          ledger_account_id: salesLedgerAccountId,
          quantity: 1,
          unit_price: 500,
          tax_rate_id: "GB_STANDARD",
        },
      ],
    },
  }),
});

Ledger accounts and tax rates differ between countries and businesses, so look them up from the API during setup instead of hard-coding IDs. The tax rate in the example above applies to UK businesses.

 

Keeping Data in Sync

Sage Accounting integrations typically rely on scheduled, incremental syncs. Instead of downloading everything each time, store the time of the last successful sync and request only records that have been created or updated since then. In a Next.js app, a cron-triggered route handler is a convenient way to run this job, protected by a secret so that only your scheduler can call it.

// app/api/cron/sage-sync/route.ts
import { sageFetch } from "@/lib/sage";
import { getLastSync, setLastSync, upsertInvoices } from "@/lib/db";

export async function GET(req: Request) {
  if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const since = await getLastSync("sales_invoices");
  const startedAt = new Date().toISOString();

  const data = await sageFetch(
    `/sales_invoices?updated_or_created_since=${encodeURIComponent(since)}&items_per_page=200`
  );
  await upsertInvoices(data.$items);

  await setLastSync("sales_invoices", startedAt);
  return Response.json({ synced: data.$items.length });
}

Responses are paginated, so production code should follow the next-page links until all records are fetched. For large datasets, move the work into a background queue to stay within serverless execution time limits, and back off when the API returns a 429 rate-limit response.

 

Conclusion

Connecting Sage with Next.js gives your application reliable, automated access to your customers' accounting data. By implementing a secure OAuth 2.0 flow with state verification, handling five-minute access tokens and rotating refresh tokens carefully, always sending the X-Business header, and syncing data incrementally, you build an integration that keeps working long after launch. Whether you're adding invoicing to a SaaS product or automating finance operations, getting the integration right is essential, and working with an experienced Next.js team that understands both Sage and software architecture ensures your financial data flows smoothly and securely.

sagenext.jssage integrationaccounting integrationweb development

Share this article

Similar Blogs

No similar posts found.

Related Service

Next.js Development Service

Would you like professional support on this topic?

View Service

Contact Us

You can reach out to us via this form

© 2024-2026 Codefacture Yazılım A.Ş. All Rights Reserved
Get a Quote

Average Response Time: 15 Minutes