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

# Receive webhooks in Express

> Register a webhook URL, verify the signature, and fulfill an order when checkout.session.completed fires. A complete, runnable Express example.

By the end of this page you have an Express endpoint that verifies AgentaOS's signature and marks an order paid when a checkout completes. It fulfills each order once, even if the same event arrives twice.

## Before you start

* Node.js 20.6 or later, and `@agentaos/pay` and Express installed (`npm install @agentaos/pay express`). The samples run on Express 4 and 5.
* A URL AgentaOS can reach over HTTPS. AgentaOS does not deliver to `localhost` or a private address, so for local development run a tunnel service that gives `http://localhost:3000` a public HTTPS address.
* A payment link or checkout to test against. See [Accept your first payment](/guides/accept-your-first-payment) if you don't have one yet.

<Steps>
  <Step title="Register a webhook URL and reveal the signing secret">
    In [app.agentaos.ai](https://app.agentaos.ai), open **Settings → Developers → Webhooks**, enter the HTTPS URL you want events sent to, for example `https://myshop.com/webhooks`, and click **Save**. Click **Reveal signing secret**, copy the `whsec_...` value, and add it to your server's environment as `AGENTAOS_WEBHOOK_SECRET`. Never commit it, never log it, never send it to the client.
  </Step>

  <Step title="Set up the raw-body route">
    Signature verification signs the **raw** request body. If a JSON body parser runs first, the reserialized body won't byte-for-byte match what was signed, and every event will fail verification. Mount `express.raw()` on the webhook route only, and keep `express.json()` for everything else.

    ```typescript theme={"system"}
    import express from 'express';

    const app = express();

    app.post('/webhooks', express.raw({ type: 'application/json' }), webhookHandler);
    app.use(express.json()); // every other route can parse JSON normally
    ```
  </Step>

  <Step title="Verify the signature">
    `webhooks.verify()` parses the `t=...,v1=...` header, rejects a timestamp older than 5 minutes or in the future, recomputes the HMAC-SHA256 digest in constant time, and returns a typed `WebhookEvent`. If any check fails, it throws `WebhookVerificationError`. Keep your server's clock synced (NTP): a clock that runs behind rejects every event.

    ```typescript theme={"system"}
    import { AgentaOS, WebhookVerificationError } from '@agentaos/pay';

    const agentaos = new AgentaOS(process.env.AGENTAOS_API_KEY!);

    function webhookHandler(req: express.Request, res: express.Response) {
      let event;
      try {
        event = agentaos.webhooks.verify(
          req.body,
          req.headers['x-agentaos-signature'] as string,
          process.env.AGENTAOS_WEBHOOK_SECRET!,
        );
      } catch (err) {
        if (err instanceof WebhookVerificationError) {
          res.status(400).send('Invalid signature');
          return;
        }
        res.status(500).send('Webhook processing failed');
        return;
      }

      // Signature is valid, safe to act on event.data now.
    }
    ```
  </Step>

  <Step title="React to checkout.session.completed, idempotently">
    Key your fulfillment logic on `event.data.sessionId`, and check whether you've already processed it before doing anything. Retries mean the same event can arrive more than once, so your handler must be safe to run twice.

    ```typescript theme={"system"}
    // Simple in-memory store keyed by sessionId. Use a real database in production,
    // with a unique constraint on sessionId to make this safe under concurrent delivery.
    const fulfilledSessions = new Set<string>();

    switch (event.type) {
      case 'checkout.session.completed': {
        const { sessionId, amount, currency, metadata } = event.data;

        if (fulfilledSessions.has(sessionId)) {
          break; // already handled this payment, skip
        }
        fulfilledSessions.add(sessionId);

        console.log(`Order ${sessionId} paid: ${amount} ${currency}`);
        // metadata.customerId is the id you passed on checkouts.create.
        // For a subscription, metadata.subscriptionId is also set.
        break;
      }
    }
    ```

    `metadata` holds what you set on `checkouts.create`, so `metadata.customerId` is your own user id. `verify()` camelCases every key, including the keys inside `metadata`: use camelCase keys in metadata, because a key like `user_id` comes back as `userId`. The event's `customerId` field is different: it is AgentaOS's own id for the buyer, not yours. A subscription renewal fires this event without your `metadata`. See [Event order for subscriptions](/webhooks/events#event-order-for-subscriptions) and [Sell a subscription](/guides/sell-a-subscription).

    `event.data.amount` is a string, such as `"49.99"`. See the [money model](/api-reference/introduction#money-model).
  </Step>

  <Step title="Respond 200 quickly">
    Verify, queue or record the event, then respond. Do slow work, emails, external API calls, outside the request so AgentaOS doesn't time out waiting for you.

    ```typescript theme={"system"}
    res.sendStatus(200);
    ```
  </Step>
</Steps>

## Full example

```typescript webhook-server.ts theme={"system"}
import { AgentaOS, WebhookVerificationError } from '@agentaos/pay';
import express from 'express';

const agentaos = new AgentaOS(process.env.AGENTAOS_API_KEY!);
const app = express();

const fulfilledSessions = new Set<string>();

app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = agentaos.webhooks.verify(
      req.body,
      req.headers['x-agentaos-signature'] as string,
      process.env.AGENTAOS_WEBHOOK_SECRET!,
    );
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      res.status(400).send('Invalid signature');
      return;
    }
    res.status(500).send('Webhook processing failed');
    return;
  }

  if (event.type === 'checkout.session.completed') {
    const { sessionId, amount, currency } = event.data;

    if (!fulfilledSessions.has(sessionId)) {
      fulfilledSessions.add(sessionId);
      console.log(`Order ${sessionId} paid: ${amount} ${currency}`);
      // fulfillOrder(sessionId);
    }
  }

  res.sendStatus(200);
});

app.use(express.json()); // other routes can parse JSON normally

app.listen(3000, () => console.log('Webhook server listening on :3000'));
```

<Tip>
  Not using Node? The signing algorithm is HMAC-SHA256 over `{timestamp}.{raw_body}`, and any language can check it. See [manual verification in Python, Go, and PHP](/payments/webhooks#manual-verification-no-sdk).
</Tip>

## Test it end to end

<Steps>
  <Step title="Start your server">
    Save the example as `webhook-server.ts`, put `AGENTAOS_API_KEY` and `AGENTAOS_WEBHOOK_SECRET` in a `.env` file, and run `npx tsx --env-file=.env webhook-server.ts`. Make sure your tunnel or production URL points at it.
  </Step>

  <Step title="Create a checkout with a webhookUrl">
    Use the link or checkout from [Accept your first payment](/guides/accept-your-first-payment), or create a new one with `webhookUrl` set to your endpoint.
  </Step>

  <Step title="Pay with the test card">
    `4242 4242 4242 4242`, any future expiry, any CVC, typed directly into the hosted checkout page.
  </Step>

  <Step title="Watch your server log the order as paid">
    You should see `Order <sessionId> paid: 49.99 EUR` in your logs within seconds of the payment clearing.
  </Step>
</Steps>

## Verify it worked

* Your endpoint returned `200` for the delivery. The event list in **Settings → Developers → Webhooks** shows each delivery's status and response code.
* Your logs show exactly one fulfillment for that `sessionId`, even if AgentaOS retries the delivery.
* An invalid or missing `X-AgentaOS-Signature` header gets rejected with `400`, not silently processed. Try POSTing a fake payload without a valid signature to confirm `webhooks.verify()` throws as expected.

## Next steps

<Columns cols={2}>
  <Card title="Event reference" icon="bell" href="/webhooks/events">
    Every event type, its full payload, and a JSON example.
  </Card>

  <Card title="Webhooks (concept)" icon="tower-broadcast" href="/payments/webhooks">
    Manual verification in Python, Go, and PHP, plus retry and delivery details.
  </Card>

  <Card title="Accept your first payment" icon="credit-card" href="/guides/accept-your-first-payment">
    Create the checkout that triggers this handler.
  </Card>

  <Card title="Payouts" icon="money-bill-transfer" href="/payouts/overview">
    What happens to your balance after the payment lands.
  </Card>
</Columns>
