How to Connect Sage with Laravel

By Codefacture7 min read

How to Connect Sage with Laravel?

 

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 Laravel applications, 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 a few operational details regularly 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. Laravel has the right tools to handle all of them. In this guide, we'll connect Sage Accounting with Laravel 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:8000/sage/callback during development. A trial or test business is strongly recommended, so you can create invoices freely without affecting real books.

This guide uses Laravel's built-in HTTP client rather than a third-party SDK, which keeps dependencies minimal and gives you full control over token handling. Add your credentials to the environment and register them in config/services.php.

# .env
SAGE_CLIENT_ID=...
SAGE_CLIENT_SECRET=...
SAGE_REDIRECT_URI=http://localhost:8000/sage/callback

// config/services.php
'sage' => [
    'client_id' => env('SAGE_CLIENT_ID'),
    'client_secret' => env('SAGE_CLIENT_SECRET'),
    'redirect' => env('SAGE_REDIRECT_URI'),
],

 

Storing Connections Securely

Each connection needs the current access token, refresh token, expiry time, the selected business ID, and the time of the last successful sync. Sage tokens are long, so use text columns, and apply Laravel's encrypted cast so tokens are encrypted with your application key before they reach the database.

// database/migrations/xxxx_create_sage_connections_table.php
Schema::create('sage_connections', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('business_id')->nullable();
    $table->text('access_token');
    $table->text('refresh_token');
    $table->timestamp('expires_at');
    $table->timestamp('last_synced_at')->nullable();
    $table->timestamps();
});

// app/Models/SageConnection.php
protected function casts(): array
{
    return [
        'access_token' => 'encrypted',
        'refresh_token' => 'encrypted',
        'expires_at' => 'datetime',
        'last_synced_at' => 'datetime',
    ];
}

 

Implementing the OAuth 2.0 Flow

The first action generates a random state value, stores it in the session, and redirects the user to the Sage authorization page. The full_access scope allows reading and writing data; use readonly if your integration only needs to read. The callback action verifies the state to protect against cross-site request forgery, exchanges the authorization code for tokens at the Sage token endpoint, and stores the connection.

// app/Http/Controllers/SageController.php
namespace App\Http\Controllers;

use App\Models\SageConnection;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class SageController extends Controller
{
    public function connect(Request $request)
    {
        $state = Str::random(40);
        $request->session()->put('sage_state', $state);

        $query = http_build_query([
            'filter' => 'apiv3.1',
            'response_type' => 'code',
            'client_id' => config('services.sage.client_id'),
            'redirect_uri' => config('services.sage.redirect'),
            'scope' => 'full_access',
            'state' => $state,
        ]);

        return redirect('https://www.sageone.com/oauth2/auth/central?'.$query);
    }

    public function callback(Request $request)
    {
        abort_unless(
            $request->state && $request->state === $request->session()->pull('sage_state'),
            403
        );

        $tokens = Http::asForm()
            ->post('https://oauth.accounting.sage.com/token', [
                'grant_type' => 'authorization_code',
                'code' => $request->code,
                'redirect_uri' => config('services.sage.redirect'),
                'client_id' => config('services.sage.client_id'),
                'client_secret' => config('services.sage.client_secret'),
            ])
            ->throw()
            ->json();

        SageConnection::updateOrCreate(
            ['user_id' => $request->user()->id],
            [
                'access_token' => $tokens['access_token'],
                'refresh_token' => $tokens['refresh_token'],
                'expires_at' => now()->addSeconds($tokens['expires_in']),
            ]
        );

        return redirect()->route('sage.businesses');
    }
}

 

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, especially in Laravel applications where several queue workers may call the API at the same time.

If two workers refresh the same connection simultaneously, one of them uses a refresh token that has just been invalidated, and the connection breaks. The service class below prevents this with a cache lock: only one worker refreshes, while the others wait, reload the record, and use the new token. It also sends the X-Business header on every request and retries automatically on 429 rate-limit responses.

// app/Services/SageClient.php
namespace App\Services;

use App\Models\SageConnection;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class SageClient
{
    public function __construct(private SageConnection $connection) {}

    public function request(): PendingRequest
    {
        $this->refreshTokenIfNeeded();

        return Http::withToken($this->connection->access_token)
            ->withHeaders(array_filter(['X-Business' => $this->connection->business_id]))
            ->acceptJson()
            ->baseUrl('https://api.accounting.sage.com/v3.1')
            ->retry(3, 2000, fn ($e) => $e instanceof RequestException
                && $e->response->status() === 429);
    }

    private function refreshTokenIfNeeded(): void
    {
        if ($this->connection->expires_at->isAfter(now()->addMinute())) {
            return;
        }

        // Refresh tokens rotate: two parallel refreshes would break the connection
        Cache::lock("sage-refresh-{$this->connection->id}", 10)->block(5, function () {
            $this->connection->refresh();

            if ($this->connection->expires_at->isAfter(now()->addMinute())) {
                return;
            }

            $tokens = Http::asForm()
                ->post('https://oauth.accounting.sage.com/token', [
                    'grant_type' => 'refresh_token',
                    'refresh_token' => $this->connection->refresh_token,
                    'client_id' => config('services.sage.client_id'),
                    'client_secret' => config('services.sage.client_secret'),
                ])
                ->throw()
                ->json();

            $this->connection->update([
                'access_token' => $tokens['access_token'],
                'refresh_token' => $tokens['refresh_token'], // always store the new one
                'expires_at' => now()->addSeconds($tokens['expires_in']),
            ]);
        });
    }
}

For customers who use the integration rarely, schedule a job that refreshes tokens periodically, so they never expire unused and force a reconnection.

 

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 save the selected business ID. If the X-Business header is omitted, Sage falls back to the user's lead business, which may not be the one you intend to write to.

$sage = new SageClient($connection);

// List the businesses the user can access, then save the chosen one
$businesses = $sage->request()->get('/businesses')->throw()->json();
$connection->update(['business_id' => $chosenBusinessId]);

// Create a sales invoice in the selected business
$invoice = $sage->request()
    ->post('/sales_invoices', [
        'sales_invoice' => [
            'contact_id' => $contactId,
            'date' => now()->toDateString(),
            'invoice_lines' => [[
                'description' => 'Consulting services',
                'ledger_account_id' => $salesLedgerAccountId,
                'quantity' => 1,
                'unit_price' => 500,
                'tax_rate_id' => 'GB_STANDARD',
            ]],
        ],
    ])
    ->throw()
    ->json();

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.

 

Scheduled Data Syncs

Sage Accounting integrations typically rely on scheduled, incremental syncs. Instead of downloading everything each time, store when the last successful sync started and request only records created or updated since then. An Artisan command combined with Laravel's scheduler is a natural fit: it pages through results, stores them locally, and records the sync time only after everything succeeds. The withoutOverlapping option ensures a slow sync never runs twice in parallel.

// app/Console/Commands/SyncSageInvoices.php
namespace App\Console\Commands;

use App\Models\SageConnection;
use App\Services\SageClient;
use Illuminate\Console\Command;

class SyncSageInvoices extends Command
{
    protected $signature = 'sage:sync';

    protected $description = 'Sync sales invoices from Sage Accounting';

    public function handle(): void
    {
        SageConnection::whereNotNull('business_id')->each(function (SageConnection $connection) {
            $sage = new SageClient($connection);
            $startedAt = now();
            $page = 1;

            do {
                $response = $sage->request()->get('/sales_invoices', [
                    'updated_or_created_since' => $connection->last_synced_at?->toIso8601String(),
                    'items_per_page' => 200,
                    'page' => $page++,
                ])->throw();

                foreach ($response->json('$items') as $invoice) {
                    // upsert into your local tables
                }
            } while ($response->json('$next'));

            $connection->update(['last_synced_at' => $startedAt]);
        });
    }
}

// routes/console.php
Schedule::command('sage:sync')->everyFifteenMinutes()->withoutOverlapping();

For businesses with large amounts of data, dispatch one queued job per connection from the command, so a single slow or failing business doesn't delay the others. Log each run and alert on failures, so problems are caught long before month-end.

 

Conclusion

Connecting Sage with Laravel gives your application reliable, automated access to your customers' accounting data. By implementing OAuth 2.0 with state verification, encrypting tokens at rest, refreshing five-minute tokens safely with cache locks, always sending the X-Business header, and syncing data incrementally with the scheduler, 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 team that understands both Sage and software architecture ensures your financial data flows smoothly and securely.

sagelaravelsage integrationaccounting integrationweb development

Share this article

Similar Blogs

No similar posts found.

Related Service

Laravel 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