# How the Autumn Webhook Handles Subscription Billing Events in Open-SEO

> Discover how the Autumn webhook processes subscription billing events in Open-SEO. Learn about request validation, Zod parsing, and synchronized customer status updates to Loops CRM.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-08-19

---

**The Autumn webhook validates Svix-signed POST requests at `/api/autumn/webhook`, parses subscription billing events using a Zod schema, and synchronizes customer states via `syncAutumnCustomerStatus`, which upserts deterministic snapshots to the database and pushes updates to Loops CRM.**

The **Autumn webhook** acts as the secure bridge between the Autumn subscription billing platform and the Open-SEO application state. Located in the `every-app/open-seo` repository, this endpoint ensures that every subscription billing event—whether plan changes, renewals, or cancellations—immediately reflects in the local database and downstream CRM systems.

## Webhook Endpoint Configuration

The webhook is exposed at the path `/api/autumn/webhook`, exported as the constant `AUTUMN_WEBHOOK_PATH` from [`src/server/billing/autumn-webhook.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/autumn-webhook.ts). The route handler is registered to process all HTTP methods at this endpoint, though it strictly enforces POST-only access for security.

```ts
// Register the webhook route (simplified)
import { handleAutumnWebhookRequest } from "@/server/billing/autumn-webhook";

app.all(AUTUMN_WEBHOOK_PATH, (req) => handleAutumnWebhookRequest(req));

```

## Request Validation and Security

The webhook implements multiple layers of validation to ensure request authenticity. It first rejects any non-POST requests with a **405 Method Not Allowed** response. For valid POST requests, the raw request body is verified using **Svix's HMAC signature** mechanism with the secret stored in the `AUTUMN_WEBHOOK_SECRET` environment variable.

If signature verification fails, the webhook immediately returns a **401 Unauthorized** status with a JSON error payload. This prevents attackers from injecting fake billing events into the system.

## Payload Schema Validation

After security verification, the request body is parsed and validated against the `autumnWebhookPayloadSchema` defined using **Zod**. This schema expects an object containing a `type` string and an optional `data` object. Any malformed JSON or schema violations result in a **400 Bad Request** response, ensuring only well-formed events enter the processing pipeline.

```json
{
  "type": "billing.updated",
  "data": {
    "customer_id": "org_12345",
    "plan_id": "premium",
    "status": "active"
  }
}

```

## Handling billing.updated Events

When the payload type equals `billing.updated`, the webhook extracts the `customer_id` from `payload.data`. If this identifier is missing, the endpoint returns a **400** error, as the customer context is required for all subsequent operations.

The core subscription billing event processing is delegated to the `syncAutumnCustomerStatus` function, imported from [`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 encapsulates the entire state synchronization logic, keeping the webhook handler thin and focused on HTTP concerns.

## Synchronizing the Customer Billing Status

Inside `syncAutumnCustomerStatus`, the system first retrieves the current customer record from Autumn using the SDK's `customers.getOrCreate` method. The retrieved data is then transformed into a **BillingCustomerStatusSnapshot** via the `deriveBillingCustomerStatusSnapshot` helper.

This snapshot is upserted into the `billingCustomerStatus` database table using an **idempotent** `ON CONFLICT DO UPDATE` clause, ensuring that duplicate or out-of-order webhook deliveries never create inconsistent data. Finally, the snapshot is pushed to **Loops** for CRM-side bookkeeping, maintaining alignment between the billing system and marketing automation platform.

```ts
// Manually invoke the sync logic (useful for testing)
import { syncAutumnCustomerStatus } from "@/server/billing/customer-status-sync";

await syncAutumnCustomerStatus("org_12345");

```

## Error Handling and Observability

All synchronous operations are wrapped in try-catch blocks to prevent unhandled exceptions from crashing the webhook server. When errors occur during customer status synchronization, the system logs the full error context—including the underlying database driver cause—and reports the failure to **PostHog** via the `captureServerError` utility.

The webhook responds with a **500 Internal Server Error** status when processing fails, triggering Svix's automatic retry mechanism while alerting the development team through the observability pipeline.

## Idempotency and Retry Behavior

The webhook is designed to be **idempotent** by nature. Because `syncAutumnCustomerStatus` computes deterministic snapshots and uses database upserts, re-playing or receiving out-of-order events simply re-applies the same customer state without side effects. No dedicated deduplication table is required, as the database constraints handle duplicate prevention implicitly.

When the webhook returns any non-2xx status code, **Svix automatically retries** delivery according to its exponential backoff schedule, ensuring eventual consistency even during temporary outages or deployment windows.

## Summary

- The Autumn webhook at `/api/autumn/webhook` accepts only POST requests and validates Svix HMAC signatures using `AUTUMN_WEBHOOK_SECRET`.
- Incoming payloads are parsed against the `autumnWebhookPayloadSchema` Zod validator, rejecting malformed data with 400 responses.
- Subscription billing events of type `billing.updated` trigger `syncAutumnCustomerStatus`, which fetches customer data via the Autumn SDK and derives a `BillingCustomerStatusSnapshot`.
- Customer snapshots are upserted idempotently into the `billingCustomerStatus` table and forwarded to Loops CRM for synchronization.
- Errors are captured via `captureServerError` to PostHog, with 500 responses triggering automatic Svix retries.

## Frequently Asked Questions

### How does the Autumn webhook verify the authenticity of incoming requests?

The webhook verifies requests using Svix's HMAC signature validation. It reads the raw request body and validates it against the secret stored in the `AUTUMN_WEBHOOK_SECRET` environment variable. Requests with invalid signatures receive a 401 Unauthorized response, ensuring only legitimate Autumn webhooks are processed.

### What happens if the customer_id is missing from a billing.updated event?

If the `payload.data` object lacks a `customer_id` when processing a `billing.updated` event, the webhook immediately returns a **400 Bad Request** error. This validation occurs in [`src/server/billing/autumn-webhook.ts`](https://github.com/every-app/open-seo/blob/main/src/server/billing/autumn-webhook.ts) before any database operations, preventing orphaned records or failed sync attempts.

### Is the Autumn webhook idempotent, and how does it handle duplicate deliveries?

Yes, the webhook is idempotent by design. The `syncAutumnCustomerStatus` function uses deterministic snapshot generation and database upserts with `ON CONFLICT DO UPDATE` clauses. Duplicate or out-of-order webhook deliveries simply overwrite the existing record with the same computed state, eliminating the need for a separate deduplication table while maintaining data consistency.

### Which database table stores the synchronized billing customer status?

The billing customer status is stored in the `billingCustomerStatus` table. This table receives upserts from the `syncAutumnCustomerStatus` function defined 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 the Open-SEO application always maintains the latest subscription state from Autumn.