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

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

The endpoint is wired into the main application router in 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 (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 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. 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:

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:

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:

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 uses constant-time comparison to prevent timing attacks.
  • Processing is idempotent via syncAutumnCustomerStatus in 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. 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 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 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →