# OpenSEO Webhooks: How to Integrate External Systems with the Billing API

> Integrate external systems with OpenSEO Webhooks using the billing API. Learn how to use POST /api/autumn/webhook and Svix for secure event synchronization with `AUTUMN_WEBHOOK_SECRET`.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: how-to-guide
- Published: 2026-07-30

---

**OpenSEO exposes a single webhook endpoint at `POST /api/autumn/webhook` that accepts Svix-signed billing events from external systems, verifying HMAC-SHA256 signatures via the `AUTUMN_WEBHOOK_SECRET` environment variable before synchronizing customer status.**

The `every-app/open-seo` repository provides a minimal, auditable webhook integration surface designed specifically for billing synchronization with the Autumn payment provider. Understanding the available **OpenSEO webhooks** and their verification requirements is essential for any external system needing to push real-time billing updates into the platform.

## Available OpenSEO Webhooks

OpenSEO currently maintains exactly one public webhook endpoint, reflecting a deliberate architectural choice to minimize external trust boundaries.

### The Autumn Billing Webhook Endpoint

| Attribute | Detail |
|-----------|--------|
| **Path** | `POST /api/autumn/webhook` |
| **Purpose** | Receives billing events (specifically `billing.updated`) from Autumn to synchronize organization customer records |
| **Verification** | Svix HMAC-SHA256 signature validation against `AUTUMN_WEBHOOK_SECRET` |
| **Definition** | Path constant `AUTUMN_WEBHOOK_PATH` declared in [`src/server/billing/autumn-webhook.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/autumn-webhook.ts) |

The endpoint is wired into the main application router in [`src/server.ts`](https://github.com/every-app/open-seo/blob/main/src/server.ts), where requests matching the Autumn webhook path are dispatched to the `handleAutumnWebhookRequest` function.

## How the Webhook Integration Works

The integration follows a strict six-step pipeline designed for security and idempotency.

### 1. Request Routing and Method Validation

Incoming requests hit the main handler in [`src/server.ts`](https://github.com/every-app/open-seo/blob/main/src/server.ts) (lines 152-154), which checks the pathname against `AUTUMN_WEBHOOK_PATH`. The handler strictly enforces `POST` method semantics—any other HTTP method returns **405 Method Not Allowed**.

### 2. Signature Verification

Before payload processing, the system validates the Svix signature headers:

- `svix-id` (or `webhook-id`)
- `svix-timestamp` (or `webhook-timestamp`)
- `svix-signature` (or `webhook-signature`)

The `verifySvixSignature` function in [`src/server/billing/svix.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/svix.ts) performs a constant-time HMAC-SHA256 comparison against the secret stored in `AUTUMN_WEBHOOK_SECRET`. Timestamp tolerance checks prevent replay attacks. Failure returns **401 Unauthorized** with the body `{"error": "Invalid webhook signature"}`.

### 3. Payload Parsing

The raw request body is parsed against `autumnWebhookPayloadSchema`, a Zod schema requiring:
- A `type` string field
- An optional `data` object

Malformed JSON or schema violations return **400 Bad Request** with `{"error": "Invalid webhook payload"}`.

### 4. Idempotent Processing

For `billing.updated` events, the webhook extracts `customer_id` from `payload.data` and invokes `syncAutumnCustomerStatus` in [`src/server/billing/customer-status-sync.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/customer-status-sync.ts). This function is idempotent—replayed or out-of-order deliveries converge on the same final state without requiring a deduplication table.

### 5. Response Handling

Successful processing returns **200 OK** with the JSON payload `{"received": true}`.

## Integration Steps for External Systems

To integrate with the OpenSEO webhook infrastructure:

1. **Expose the endpoint** at `https://your-domain.com/api/autumn/webhook`.
2. **Configure the secret** by generating a Svix-compatible secret (e.g., `whsec_...`) and storing it in the `AUTUMN_WEBHOOK_SECRET` environment variable.
3. **Register with Autumn** (or your Svix-compatible provider) using:
   - **URL**: `https://your-domain.com/api/autumn/webhook`
   - **Secret**: The value from step 2
   - **Events**: At minimum, `billing.updated`
4. **Send signed payloads** including the three Svix headers and a JSON body matching `{ "type": "billing.updated", "data": { "customer_id": "<org-id>" } }`.
5. **Handle response codes**:
   - **200**: Successful ingestion
   - **401**: Signature mismatch—verify secret and header formatting
   - **400**: Invalid payload structure
   - **405**: Wrong HTTP method

## Code Examples for Webhook Integration

### Sending a Test Webhook with Node.js

This example demonstrates the HMAC calculation that `verifySvixSignature` expects:

```typescript
import fetch from "node-fetch";
import crypto from "crypto";

const secret = "whsec_testsecret123"; // Must match AUTUMN_WEBHOOK_SECRET

function signPayload(id: string, timestamp: number, payload: string) {
  const signed = `${id}.${timestamp}.${payload}`;
  const rawSecret = secret.startsWith("whsec_") ? secret.slice(6) : secret;
  const hmac = crypto.createHmac("sha256", Buffer.from(rawSecret, "base64"));
  hmac.update(signed);
  return `v1,${hmac.digest("base64")}`;
}

const payload = JSON.stringify({
  type: "billing.updated",
  data: { customer_id: "org_ABC123" },
});

const id = "test-id-001";
const timestamp = Math.floor(Date.now() / 1000);
const signature = signPayload(id, timestamp, payload);

await fetch("https://your-domain.com/api/autumn/webhook", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "svix-id": id,
    "svix-timestamp": String(timestamp),
    "svix-signature": signature,
  },
  body: payload,
});

```

### Using the Official Svix SDK

For production integrations, use the official library to handle header generation:

```typescript
import Svix from "svix";
import fetch from "node-fetch";

const webhook = new Svix("whsec_testsecret123").webhook();
const payload = { type: "billing.updated", data: { customer_id: "org_ABC123" } };
const id = crypto.randomUUID();
const timestamp = Math.floor(Date.now() / 1000);
const signature = webhook.sign(JSON.stringify(payload), id, timestamp);

await fetch("https://your-domain.com/api/autumn/webhook", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "svix-id": id,
    "svix-timestamp": String(timestamp),
    "svix-signature": signature,
  },
  body: JSON.stringify(payload),
});

```

### Testing with cURL

Verify connectivity manually:

```bash
curl -X POST https://your-domain.com/api/autumn/webhook \
  -H "Content-Type: application/json" \
  -H "svix-id: test-id-001" \
  -H "svix-timestamp: $(date +%s)" \
  -H "svix-signature: v1,<base64-encoded-hmac>" \
  -d '{"type":"billing.updated","data":{"customer_id":"org_ABC123"}}'

```

## Summary

- OpenSEO exposes exactly **one webhook endpoint** (`POST /api/autumn/webhook`) for Autumn billing integration.
- All requests must include **Svix HMAC-SHA256 signatures** verified against the `AUTUMN_WEBHOOK_SECRET` environment variable.
- The implementation in [`src/server/billing/svix.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/svix.ts) uses constant-time comparison to prevent timing attacks.
- Processing is **idempotent** via `syncAutumnCustomerStatus` in [`src/server/billing/customer-status-sync.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/customer-status-sync.ts), ensuring safe replay of events.
- Valid requests return `{"received": true}`; invalid signatures return **401**, and malformed payloads return **400**.

## Frequently Asked Questions

### What is the OpenSEO webhook URL path?

The webhook path is `POST /api/autumn/webhook`, defined by the constant `AUTUMN_WEBHOOK_PATH` in [`src/server/billing/autumn-webhook.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/autumn-webhook.ts). This is the sole external entry point for webhook integrations in the current version of the repository.

### How does OpenSEO verify webhook signatures?

OpenSEO uses the `verifySvixSignature` function in [`src/server/billing/svix.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/svix.ts) to validate the `svix-signature` header against the `AUTUMN_WEBHOOK_SECRET` using HMAC-SHA256. The verification includes timestamp tolerance checks and constant-time string comparison to prevent timing attacks.

### What HTTP status codes does the OpenSEO webhook return?

The endpoint returns **200 OK** for successful processing, **401 Unauthorized** for signature verification failures, **400 Bad Request** for invalid JSON or schema violations, and **405 Method Not Allowed** for non-POST requests.

### Can external systems send custom event types to OpenSEO?

Currently, no. The `autumnWebhookPayloadSchema` in [`src/server/billing/autumn-webhook.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/autumn-webhook.ts) specifically handles the `billing.updated` event type from Autumn. The repository Greptile policy requires explicit verification for each webhook type, so additional event types would require code changes and security review before integration.