> ## 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.

# Webhook Integration

> Keep your app in sync with Macropay — get signed, real-time events for orders, subscriptions, and customer state.

Macropay is your Merchant of Record: we're the seller on the customer's statement, we collect and remit sales tax and VAT worldwide, and we run the billing ledger. Webhooks are how your application stays in lockstep with that ledger. Every time money moves, a subscription changes state, or a customer's entitlements shift, we POST a signed event to your endpoint — so you can provision access, update your database, and react in real time without polling our API.

This guide takes you from zero to a verified, production-ready handler.

## What you'll need

* A [Macropay account](https://macropay.ai/signup) with an organization
* An organization access token — see [Authentication](/integrate/authentication)
* An endpoint reachable over HTTPS (for local work, tunnel with [ngrok](https://ngrok.com))

## Register an endpoint

Create the endpoint from the dashboard or the API. Either way, you'll get back a signing secret (`whsec_...`) — store it like a password; you'll use it to verify every delivery.

### In the dashboard

1. Open your organization **Settings** → **Webhooks**
2. Click **Add Endpoint**
3. Paste your handler URL, e.g. `https://your-app.com/api/webhooks/macropay`
4. Tick the events you care about
5. **Create**, then copy the signing secret

### Via the API

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.macropay.ai/v1/webhooks/endpoints \
    -H "Authorization: Bearer $MACROPAY_ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-app.com/api/webhooks/macropay",
      "events": [
        "order.paid",
        "order.refunded",
        "subscription.active",
        "subscription.revoked",
        "customer.state_changed"
      ],
      "format": "raw"
    }'
  ```

  ```ts TypeScript theme={null}
  import { Macropay } from "@macropayments/sdk";

  const macropay = new Macropay({
    accessToken: process.env.MACROPAY_ACCESS_TOKEN!,
    server: "sandbox",
  });

  const endpoint = await macropay.webhooks.endpoints.create({
    url: "https://your-app.com/api/webhooks/macropay",
    events: [
      "order.paid",
      "order.refunded",
      "subscription.active",
      "subscription.revoked",
      "customer.state_changed",
    ],
    format: "raw",
  });

  // Store this secret in your secrets manager — it's shown once.
  console.log("Webhook secret:", endpoint.secret);
  ```

  ```py Python theme={null}
  from macropay import Macropay
  import os

  client = Macropay(
      access_token=os.environ["MACROPAY_ACCESS_TOKEN"],
      server="sandbox",
  )

  endpoint = client.webhooks.endpoints.create(
      url="https://your-app.com/api/webhooks/macropay",
      events=[
          "order.paid",
          "order.refunded",
          "subscription.active",
          "subscription.revoked",
          "customer.state_changed",
      ],
      format="raw",
  )

  # Store this secret in your secrets manager — it's shown once.
  print(f"Webhook secret: {endpoint.secret}")
  ```
</CodeGroup>

## Pick your events

<Note>
  **"The customer just paid" → listen for [`order.paid`](/api-reference/webhooks/order-paid).** It is the single signal for money cleared, across **every** flow:

  | Flow                         | What fires on payment                       | Key field                                      |
  | ---------------------------- | ------------------------------------------- | ---------------------------------------------- |
  | One-time purchase            | `order.paid`                                | —                                              |
  | Subscription — first payment | `order.paid` **then** `subscription.active` | `order.billing_reason = "subscription_create"` |
  | Subscription — each renewal  | `order.paid`                                | `order.billing_reason = "subscription_cycle"`  |

  `checkout.updated → status: succeeded` is a *funnel* signal, not a fulfillment one — it can arrive before the order is fully paid. **Grant access on `order.paid`, not on a checkout event.** Distinguish one-time vs. subscription orders with `order.subscription_id` / `order.billing_reason`.
</Note>

Subscribe to only what your integration acts on. A few of the most common:

| Event                    | Fires when                                               | Typical reaction                                                 |
| ------------------------ | -------------------------------------------------------- | ---------------------------------------------------------------- |
| `order.paid`             | A one-time or recurring payment clears                   | Grant the license key, unlock the download, mark the plan active |
| `order.refunded`         | An order is refunded                                     | Reverse the grant, adjust your records                           |
| `subscription.created`   | A subscription is created                                | Start onboarding                                                 |
| `subscription.active`    | A subscription becomes active                            | Provision the paid tier                                          |
| `subscription.canceled`  | A subscription is canceled (ends at period end)          | Schedule downgrade for the period boundary                       |
| `subscription.revoked`   | A subscription is cut off now (e.g. failed payment)      | Revoke Discord / GitHub access immediately                       |
| `checkout.created`       | A checkout session starts                                | Track funnel entry                                               |
| `checkout.updated`       | A checkout session's status changes                      | Track funnel progress                                            |
| `customer.created`       | A new customer is created                                | Sync to your CRM                                                 |
| `customer.state_changed` | A customer's active subscriptions or entitlements change | Re-evaluate access in one place                                  |

<Tip>
  If you only want a single source of truth for "what does this customer have access to right now," subscribe to `customer.state_changed` and read the entitlement set from the payload — it folds order and subscription changes into one signal. You can also query it on demand; see [Customer State](/integrate/customer-state).
</Tip>

The full catalog — including events for usage-based meters and AI/agent billing — lives in the [event reference](/integrate/webhooks/events).

## Verify, then handle

Every delivery is signed using the [Standard Webhooks](https://www.standardwebhooks.com/) spec and carries three headers: `webhook-id`, `webhook-timestamp`, and `webhook-signature`. **Always verify against the raw request body before you trust a payload** — never parse JSON first, or you'll break the signature check.

### Next.js (App Router)

```ts theme={null}
// src/app/api/webhooks/macropay/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.MACROPAY_WEBHOOK_SECRET!);

export async function POST(req: NextRequest) {
  const body = await req.text(); // raw body — required for verification
  const headers = {
    "webhook-id": req.headers.get("webhook-id")!,
    "webhook-timestamp": req.headers.get("webhook-timestamp")!,
    "webhook-signature": req.headers.get("webhook-signature")!,
  };

  let payload: any;
  try {
    payload = wh.verify(body, headers);
  } catch {
    return NextResponse.json({ error: "invalid signature" }, { status: 401 });
  }

  switch (payload.type) {
    case "order.paid":
      // Fulfill: e.g. issue a license key for the purchased product
      await db.licenses.create({
        data: {
          customerEmail: payload.data.customer.email,
          product: payload.data.product.name,
        },
      });
      break;
    case "subscription.revoked":
      // Payment failed or access pulled — cut them off now
      await db.users.update({
        where: { id: payload.data.customer.id },
        data: { active: false },
      });
      break;
  }

  return NextResponse.json({ received: true });
}
```

### FastAPI (Python)

```py theme={null}
from fastapi import FastAPI, Request, HTTPException
from standardwebhooks.webhooks import Webhook
import os

app = FastAPI()

@app.post("/api/webhooks/macropay")
async def handle_webhook(request: Request):
    body = await request.body()  # raw bytes — required for verification
    headers = {
        "webhook-id": request.headers.get("webhook-id"),
        "webhook-timestamp": request.headers.get("webhook-timestamp"),
        "webhook-signature": request.headers.get("webhook-signature"),
    }

    wh = Webhook(os.environ["MACROPAY_WEBHOOK_SECRET"])

    try:
        payload = wh.verify(body.decode("utf-8"), headers)
    except Exception:
        raise HTTPException(status_code=401, detail="Invalid signature")

    match payload["type"]:
        case "order.paid":
            order = payload["data"]
            # Provision access for order["product"]
        case "subscription.revoked":
            subscription = payload["data"]
            # Revoke access immediately

    return {"status": "ok"}
```

### Express.js

```ts theme={null}
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();

app.post(
  "/api/webhooks/macropay",
  express.raw({ type: "application/json" }), // keep the body raw
  (req, res) => {
    const body = req.body.toString();
    const headers = {
      "webhook-id": req.headers["webhook-id"] as string,
      "webhook-timestamp": req.headers["webhook-timestamp"] as string,
      "webhook-signature": req.headers["webhook-signature"] as string,
    };

    const wh = new Webhook(process.env.MACROPAY_WEBHOOK_SECRET!);

    try {
      const payload = wh.verify(body, headers);
      // Dispatch on payload.type
      res.status(200).send("OK");
    } catch {
      res.status(401).send("Invalid signature");
    }
  }
);
```

For a line-by-line walkthrough of the signing scheme, see [Signature verification](/integrate/webhooks/verification).

## Test before you ship

### Tunnel to localhost

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

Use the public ngrok URL as your endpoint, e.g. `https://abc123.ngrok-free.app/api/webhooks/macropay`. Point your **sandbox** endpoint at it so test events never touch production.

### Fire a real event

1. Spin up a [checkout link](/guides/quickstart#step-3-generate-a-checkout-link) for one of your products
2. Pay with the sandbox test card `4242 4242 4242 4242`
3. Confirm the `order.paid` event lands at your handler and your fulfillment logic runs

<Tip>
  Prefer to stay in the terminal? The Macropay CLI can replay and forward events to your local handler — see [local webhook testing](/integrate/webhooks/locally).
</Tip>

## Harden for production

### Deduplicate with `webhook-id`

The same event may be delivered more than once (that's how at-least-once delivery guarantees nothing is lost). Treat `webhook-id` as an idempotency key:

```ts theme={null}
const webhookId = req.headers.get("webhook-id");

const seen = await db.processedWebhooks.findUnique({
  where: { webhookId },
});

if (seen) {
  return new Response("Already processed", { status: 200 });
}

// ...do the work, then record it...
await db.processedWebhooks.create({ data: { webhookId } });
```

### Acknowledge fast, work later

Verify the signature, hand the payload to a queue, and return `200` right away. Long-running fulfillment (emails, provisioning, third-party calls) belongs in a worker, not the request cycle:

```ts theme={null}
export async function POST(req: Request) {
  const payload = verifyWebhook(req); // throws on bad signature
  await queue.publish("macropay-webhook", payload);
  return new Response("OK", { status: 200 });
}
```

<Warning>
  Respond with a `2xx` within 30 seconds. Anything else (or a timeout) is treated as a failure and retried with exponential backoff. See [delivery & retries](/integrate/webhooks/delivery) for the schedule and guarantees.
</Warning>

## Where to go next

<CardGroup cols={2}>
  <Card title="Event reference" icon="bell" href="/integrate/webhooks/events">
    Every event type and payload shape, including usage and agent-billing events
  </Card>

  <Card title="Signature verification" icon="shield" href="/integrate/webhooks/verification">
    How Standard Webhooks signing works under the hood
  </Card>

  <Card title="Delivery & retries" icon="truck" href="/integrate/webhooks/delivery">
    Retry backoff, ordering, and delivery guarantees
  </Card>

  <Card title="Customer state" icon="user" href="/integrate/customer-state">
    Query a customer's live entitlements on demand
  </Card>
</CardGroup>
