> ## Documentation Index
> Fetch the complete documentation index at: https://docs.macropay.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrate Macropay with Laravel

> Sell products from a Laravel app with hosted checkout, webhooks, and Macropay handling tax, compliance, and payouts as merchant of record.

<img src="https://mintcdn.com/macrodeepinc/rPF293t5zy_Onjx_/assets/guides/laravel/hero.jpeg?fit=max&auto=format&n=rPF293t5zy_Onjx_&q=85&s=5a95880a515174f02b190f9c285fcbdc" width="1920" height="1080" data-path="assets/guides/laravel/hero.jpeg" />

This guide wires Macropay into a Laravel app end to end: list your catalog, send buyers to a hosted checkout, confirm the order, and process webhooks reliably with a signed, queue-backed handler.

Because Macropay is your **merchant of record**, you don't touch raw card data and you don't file sales tax or VAT. We're the legal seller on the buyer's statement, we calculate and remit tax worldwide, and your Laravel app only ever talks to a clean REST API. Card details are tokenized in our PCI DSS Level 1 compliant vault — they never reach your server.

<Tip>
  Build against the **Sandbox** first. Point requests at `sandbox-api.macropay.ai`, run a full purchase with test cards, and confirm webhooks land before flipping a single value to go live.
</Tip>

## What you'll build

<Steps>
  <Step title="Authenticate">
    Store an organization access token as an environment variable.
  </Step>

  <Step title="List products">
    Fetch your catalog from the API and render it in a Blade view.
  </Step>

  <Step title="Create a checkout">
    Open a hosted checkout session and redirect the buyer to pay.
  </Step>

  <Step title="Confirm + verify">
    Read the checkout back on your confirmation page, then trust the webhook.
  </Step>

  <Step title="Handle webhooks">
    Verify signatures, queue the payload, and react to order and subscription events.
  </Step>
</Steps>

## 1. Set the API key

Macropay authenticates with an **organization access token**. Create one under your organization settings in the [dashboard](https://app.macropay.ai), then expose it to Laravel:

```bash Terminal theme={null}
# .env
MACROPAY_API_KEY="macropay_oat..."
```

You'll add the webhook secret to this same file in a later step.

## 2. List your products

In Macropay, everything you sell — a one-time license, a monthly plan, a usage-metered API — is a **product** with one or more prices. Fetch them with a single GET.

Register the route:

```php theme={null}
// routes/web.php
Route::get('/products', [ProductsController::class, 'handle']);
```

Then create the controller. Swap `sandbox-api.macropay.ai` for `api.macropay.ai` when you go live:

```php theme={null}
// app/Http/Controllers/ProductsController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class ProductsController extends Controller
{
    public function handle(Request $request)
    {
        $response = Http::get('https://sandbox-api.macropay.ai/v1/products', [
            'is_archived' => false,
        ]);

        $products = $response->json();

        return view('products', ['products' => $products['items']]);
    }
}
```

Render each product with a buy link. The link carries the **price ID** — that's what determines what the buyer is charged at checkout:

```php theme={null}
// resources/views/products.blade.php
@foreach ($products as $product)
    <div>
        <h3>{{ $product['name'] }}</h3>
        <a href="/checkout?priceId={{ $product['prices'][0]['id'] }}">Buy</a>
    </div>
@endforeach
```

The `/checkout` route is next.

## 3. Open a checkout session

This route creates a hosted checkout session and redirects the buyer to the Macropay-hosted payment page. After payment, Macropay sends them to your confirmation URL.

```php theme={null}
// routes/web.php
Route::get('/checkout', [CheckoutController::class, 'handle']);
```

Macropay substitutes the literal `{CHECKOUT_ID}` token in your `success_url` with the real checkout ID on redirect — so your confirmation page knows which session to read back:

```php theme={null}
// app/Http/Controllers/CheckoutController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class CheckoutController extends Controller
{
    public function handle(Request $request)
    {
        $productPriceId = $request->query('priceId', '');
        $confirmationUrl = $request->getSchemeAndHttpHost()
            . '/confirmation?checkout_id={CHECKOUT_ID}';

        $result = Http::withHeaders([
            'Authorization' => 'Bearer ' . env('MACROPAY_API_KEY'),
            'Content-Type' => 'application/json',
        ])->post('https://sandbox-api.macropay.ai/v1/checkouts/custom/', [
            'product_price_id' => $productPriceId,
            'success_url' => $confirmationUrl,
            'payment_processor' => 'stripe',
        ]);

        $checkout = $result->json();

        return redirect($checkout['url']);
    }
}
```

Any link to `/checkout?priceId={priceId}` — like the buy buttons from step 2 — now opens a fully hosted, localized, PCI-handled checkout. Tax is computed automatically based on the buyer's location.

## 4. Confirm the purchase

On redirect, read the checkout back so you can show an order summary:

```php theme={null}
// routes/web.php
Route::get('/confirmation', [ConfirmationController::class, 'handle']);
```

```php theme={null}
// app/Http/Controllers/ConfirmationController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ConfirmationController extends Controller
{
    public function handle(Request $request)
    {
        $response = Http::withHeaders([
            'Authorization' => 'Bearer ' . env('MACROPAY_API_KEY'),
            'Content-Type' => 'application/json',
        ])->get(
            'https://sandbox-api.macropay.ai/v1/checkouts/custom/'
            . $request->query('checkout_id')
        );

        $checkout = $response->json();

        Log::info(json_encode($checkout, JSON_PRETTY_PRINT));

        return view('confirmation', ['checkout' => $checkout]);
    }
}
```

<Warning>
  A redirect is **not** proof of payment. The checkout starts as `confirmed` and only becomes a fulfilled sale once you receive a `checkout.updated` webhook with status `succeeded`. Grant access from the webhook, never from the confirmation page alone.
</Warning>

## 5. Handle webhooks

Webhooks keep your database in sync with what's happening inside Macropay — orders, subscriptions, refunds, and more. This is the source of truth for fulfillment.

Add an endpoint from your organization's settings page using **Add Endpoint**.

### Expose localhost during development

Macropay needs a public HTTPS URL to deliver events. In development, a tunnel such as [ngrok](https://ngrok.com/) or Cloudflare Tunnel forwards a public URL to your local server:

```bash Terminal theme={null}
ngrok http 3000
```

### Register the endpoint

<Steps>
  <Step title="Set the URL">
    Point the endpoint at `your-app.com/api/webhook/macropay`. With a tunnel it looks like `https://<your-tunnel-id>.ngrok-free.app/api/webhook/macropay`.
  </Step>

  <Step title="Pick events">
    Subscribe to the events you care about — see the full list in the [webhook events reference](/api-reference#webhooks).
  </Step>

  <Step title="Generate a secret">
    Create a signing secret. Every delivery is signed with it so you can prove the request really came from Macropay.
  </Step>

  <Step title="Store the secret">
    Add it to your environment alongside the API key.
  </Step>
</Steps>

```bash Terminal theme={null}
# .env
MACROPAY_API_KEY="macropay_oat..."
MACROPAY_WEBHOOK_SECRET="..."
```

### Install the packages

Macropay signs deliveries using the [Standard Webhooks](https://www.standardwebhooks.com/) spec. Install the verification library plus Spatie's webhook client, which checks the signature and queues each payload:

```bash Terminal theme={null}
composer require standard-webhooks/standard-webhooks:dev-main
composer require spatie/laravel-webhook-client
```

### Wire up the route

```php theme={null}
// routes/api.php
Route::webhooks('/webhook/macropay');
```

Make sure `routes/api.php` is registered in your application bootstrap:

```php theme={null}
// bootstrap/app.php
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        //
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })->create();
```

### Configure the client

Publish the config and point it at your signature validator and processing job:

```bash Terminal theme={null}
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-config"
```

Edit the generated `config/webhook-client.php` so the secret, signature header, validator, and job all line up with Macropay:

```php theme={null}
// config/webhook-client.php
<?php
return [
    'configs' => [
        [
            /*
             * Use 'default' if you only receive webhooks on a single endpoint.
             */
            'name' => 'default',

            /*
             * Shared secret used to verify the payload was not tampered with.
             */
            'signing_secret' => env('MACROPAY_WEBHOOK_SECRET'),

            /*
             * Macropay signs deliveries with the Standard Webhooks header.
             */
            'signature_header_name' => 'webhook-signature',

            /*
             * Validates the signature header. Implements
             * \Spatie\WebhookClient\SignatureValidator\SignatureValidator
             */
            'signature_validator' => App\Handler\MacropaySignature::class,

            /*
             * Decides whether a given webhook call is stored and processed.
             */
            'webhook_profile' => \Spatie\WebhookClient\WebhookProfile\ProcessEverythingWebhookProfile::class,

            /*
             * Builds the HTTP response returned for a valid webhook call.
             */
            'webhook_response' => \Spatie\WebhookClient\WebhookResponse\DefaultRespondsTo::class,

            /*
             * Model used to persist webhook calls. Equal to or extends
             * Spatie\WebhookClient\Models\WebhookCall.
             */
            'webhook_model' => \Spatie\WebhookClient\Models\WebhookCall::class,

            /*
             * Headers to store on the webhook call model. Set to `*` for all.
             */
            'store_headers' => [],

            /*
             * Job that processes the request. Extends
             * \Spatie\WebhookClient\Jobs\ProcessWebhookJob.
             */
            'process_webhook_job' => App\Handler\ProcessWebhook::class,
        ],
    ],

    /*
     * Delete stored webhook calls after this many days. 30 keeps a month;
     * set to null to keep them indefinitely.
     */
    'delete_after_days' => 30,
];
```

### Prepare the database and queue

Webhook calls are persisted before processing, so publish and run the migration:

```bash Terminal theme={null}
php artisan vendor:publish --provider="Spatie\WebhookClient\WebhookClientServiceProvider" --tag="webhook-client-migrations"
php artisan migrate
```

Process events off the request cycle with a queue. Set `QUEUE_CONNECTION=database` in `.env` (or use Redis), then create and migrate the jobs table:

```bash Terminal theme={null}
php artisan queue:table
php artisan migrate
```

### Verify the signature

Create an `app/Handler` folder with two classes. The first rejects any request that isn't genuinely from Macropay:

```php theme={null}
// app/Handler/MacropaySignature.php
<?php

namespace App\Handler;

use Illuminate\Http\Request;
use Spatie\WebhookClient\WebhookConfig;
use Spatie\WebhookClient\SignatureValidator\SignatureValidator;

class MacropaySignature implements SignatureValidator
{
    public function isValid(Request $request, WebhookConfig $config): bool
    {
        $signingSecret = base64_encode($config->signingSecret);
        $wh = new \StandardWebhooks\Webhook($signingSecret);

        return boolval($wh->verify($request->getContent(), array(
            "webhook-id" => $request->header("webhook-id"),
            "webhook-signature" => $request->header("webhook-signature"),
            "webhook-timestamp" => $request->header("webhook-timestamp"),
        )));
    }
}
```

### Process the event

The second class extends `ProcessWebhookJob` and branches on the event type. Fulfillment, access grants, and subscription state all live here:

```php theme={null}
// app/Handler/ProcessWebhook.php
<?php

namespace App\Handler;

use Illuminate\Support\Facades\Log;
use Spatie\WebhookClient\Jobs\ProcessWebhookJob;

class ProcessWebhook extends ProcessWebhookJob
{
    public function handle()
    {
        $decoded = json_decode($this->webhookCall, true);
        $data = $decoded['payload'];

        switch ($data['type']) {
            case "checkout.created":
                // A checkout session was opened
                break;
            case "checkout.updated":
                // Fulfill the order once status === "succeeded"
                break;
            case "subscription.created":
                // A new subscription was created
                break;
            case "subscription.updated":
                // Plan, seats, or billing details changed
                break;
            case "subscription.active":
                // Grant access — subscription is paid and live
                break;
            case "subscription.revoked":
                // Revoke access — billing period has ended
                break;
            case "subscription.canceled":
                // Buyer canceled; keep access until the period ends
                break;
            default:
                Log::info($data['type']);
                break;
        }

        http_response_code(200);
    }
}
```

Run the worker so queued jobs get processed:

```bash Terminal theme={null}
php artisan queue:listen
```

Your app is now receiving and verifying live webhook events.

### Subscription lifecycle, done right

<Note>
  **Grant on `subscription.active`, revoke on `subscription.revoked`.** When a buyer cancels you'll get `subscription.canceled` — but they've usually paid through the end of the period. Don't pull access immediately; wait for `subscription.revoked` (or the period end) so paying customers keep what they bought.
</Note>

## Going live

When you're ready for real payments:

| Change           | From                      | To                            |
| ---------------- | ------------------------- | ----------------------------- |
| API base URL     | `sandbox-api.macropay.ai` | `api.macropay.ai`             |
| API key          | sandbox token             | production `MACROPAY_API_KEY` |
| Webhook endpoint | tunnel URL                | your production URL + secret  |

Once you flip those, Macropay handles tax, fraud, chargebacks, and payouts (free ACH and SEPA) as merchant of record — so your Laravel app stays focused on the product, not the paperwork.

## Beyond checkout

The same API powers more than one-time purchases:

<CardGroup cols={2}>
  <Card title="Usage-based billing" icon="gauge">
    Meter ingested events and bill on consumption — perfect for API products and per-request pricing.
  </Card>

  <Card title="AI & agent billing" icon="robot">
    Bill AI features by usage, activity, or outcome, and attribute revenue and cost per agent.
  </Card>
</CardGroup>

## Real-time confirmation (optional)

Building a reactive UI? On the confirmation page, listen for the `checkout.updated` event and update the view the moment its status flips to `succeeded`, instead of relying on a page refresh.

Stuck or want to compare notes? Join [our Discord](https://discord.gg/Pnhfz3UThd) — we're happy to help.
