# Guide to Autumn Webhooks: Features and Best Practices

Autumn webhooks tell your application when a customer's plan changes, when they hit a usage limit or alert, and when an automatic top-up succeeds or fails. Autumn is an open-source billing and entitlements layer that sits on top of Stripe and keeps subscription state for you, so webhooks aren't needed to run billing. They are for the side effects: welcome emails, provisioning, CRM updates and cutting off access when someone cancels.

This guide covers how Autumn webhook endpoints are created and signed, the events and their payloads, how retries work, and the practices that keep an Autumn receiver reliable.

## What are Autumn webhooks?

An Autumn webhook endpoint is a URL plus the events it should receive, either all of them or a selection. When an event happens for one of your customers, Autumn sends a JSON `POST` to the endpoint.

Autumn delivers webhooks through Svix. Each request carries `svix-id`, `svix-timestamp` and `svix-signature` headers, and Autumn recommends the Svix libraries for verification. The signing secret starts with `whsec_` and is shown only once.

## Autumn webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (**Developer > Webhooks**) or `POST /v1/webhooks.create` |
| Subscription | All events, or a selection. `vercel.*` events can't be mixed with other events |
| Environments | Webhooks and secrets are per environment |
| Endpoint URL | HTTPS. Localhost and private-network addresses are rejected |
| Payload | `{ "type": "...", "data": { ... } }` |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | Svix (Standard Webhooks): HMAC-SHA256, base64, entries prefixed with `v1,` |
| Signed content | `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | `whsec_...`, returned once when the webhook is created |
| Testing | **Send Test Event** in the dashboard |
| Retries | Exponential backoff on errors and unavailability |
| Delivery log | View and retry delivery attempts from the dashboard |

Autumn publishes no response timeout or source IP list.

## Common events

| Event | Sent when |
| --- | --- |
| `billing.updated` | A customer's plans change: new subscriptions, upgrades, downgrades, cancellations, expiries |
| `balances.limit_reached` | A customer hits a usage limit for a feature |
| `balances.usage_alert_triggered` | A customer crosses a configured usage alert threshold |
| `billing.auto_topup_succeeded` | An automatic top-up grants more prepaid balance |
| `billing.auto_topup_failed` | An automatic top-up is blocked, declined or fails |
| `invoice.finalized` | A Stripe invoice is finalized and its line items reconciled |
| `vercel.resources.provisioned`, `vercel.resources.deleted`, `vercel.resources.rotate_secrets`, `vercel.webhooks.event` | Vercel Marketplace events passed through to you |

The create-webhook API also accepts `customer.products.updated` and `customer.threshold_reached`, which Autumn doesn't describe further.

`billing.updated` is the one most integrations need. Each event carries a `plan_changes` array, and each entry has an `action` (`activated`, `scheduled`, `updated` or `expired`), the `subscription` after the change, and `previous_attributes` with the old values of anything that changed. Here is Autumn's example of an upgrade from `free` to `pro`, trimmed:

```json
{
  "type": "billing.updated",
  "data": {
    "object": "billing.updated",
    "customer_id": "user_123",
    "plan_changes": [
      {
        "action": "activated",
        "subscription": {
          "plan_id": "pro",
          "status": "active",
          "canceled_at": null,
          "current_period_start": 1761840000000,
          "current_period_end": 1764432000000
        },
        "previous_attributes": null,
        "item_changes": []
      },
      {
        "action": "expired",
        "subscription": { "plan_id": "free", "status": "expired", "expires_at": 1761840000000 },
        "previous_attributes": { "status": "active" },
        "item_changes": []
      }
    ],
    "tags": []
  }
}

```

* `type` is the event type. Route on it.
* `data.customer_id` is your customer ID, and entity-scoped events add `data.entity_id`.
* `data.tags` can hold `trial_ended` or `phase_changed`, explaining why the event fired.
* Timestamps in the payload are in milliseconds.

`balances.limit_reached` carries `customer_id`, `feature_id` and a `limit_type` of `included`, `max_purchase` or `spend_limit`. The top-up events add a top-level `id` and `occurred_at`, and `billing.auto_topup_failed` carries a `reason` to branch on.

## Setting up Autumn webhooks

### In the dashboard

1. Go to the **Developer** section and open the **Webhooks** tab.
2. Click **Add Endpoint** and enter your URL.
3. Choose all events or specific ones.
4. Save, copy the signing secret straight away, and store it server-side as `AUTUMN_WEBHOOK_SECRET`.
5. Use **Send Test Event** to check your endpoint.

### Through the API

```bash
curl -X POST https://api.useautumn.com/v1/webhooks.create \
  -H "Authorization: Bearer $AUTUMN_SECRET_KEY" \
  -H "x-api-version: 2.6.0" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "billing",
    "url": "https://example.com/webhooks/autumn",
    "events": ["billing.updated", "balances.limit_reached", "invoice.finalized"]
  }'

```

You choose the `id`, and it can't be changed later. The URL must be HTTPS: localhost and private-network addresses are rejected, though tunnels work. Leave out `events` to receive everything. The response contains the `whsec_` secret, and Autumn shows it only this once, so store it before you discard the response.

Each Autumn environment has its own webhooks, so create one for sandbox and one for production, each with its own secret.

If you use Hookdeck, create a source with the Autumn source type, paste the whole `whsec_` secret into it, and use the Hookdeck source URL as the endpoint URL.

## Securing Autumn webhooks

To verify manually, join `svix-id`, `svix-timestamp` and the raw request body with dots. Take the part of the secret after `whsec_`, base64-decode it, and use the bytes as the HMAC-SHA256 key. Encode the result as base64 and compare it in constant time with each `v1,` entry in the space-delimited `svix-signature` header. A match on any entry is enough.

Autumn names no timestamp tolerance. The check below is optional and off by default. If you turn it on, remember that a delivery replayed later, for example from Hookdeck, keeps its original timestamp.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyAutumnSignature(rawBody, headers, secret, toleranceSeconds = 0) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
  if (!/^\d+$/.test(timestamp)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/autumn', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyAutumnSignature(req.body, req.headers, process.env.AUTUMN_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the svix-id header, then route on event.type.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hashlib
import hmac
import time

def verify_autumn_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
        return False
    if not timestamp.isdigit():
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    try:
        key = base64.b64decode(secret[len("whsec_"):])
    except (binascii.Error, ValueError):
        return False
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_autumn_signature(raw_body, request.headers, os.environ["AUTUMN_WEBHOOK_SECRET"])

```

The Svix libraries that Autumn recommends do the same check, but they always enforce a five-minute timestamp window. A delivery that Hookdeck retries or replays more than five minutes after Autumn sent it fails the library's check even though its signature is valid. The [Autumn webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/autumn-webhooks) includes a manual verifier where you can drop or widen the window.

> **Make Autumn webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every Autumn delivery, stores each billing and usage event, and delivers it to your provisioning, email and CRM services on a schedule you control.

## Autumn webhook limitations and pain points

### The secret is shown once

**The Problem:** You lose the signing secret and can't read it back.

**Why It Happens:** Autumn returns the `whsec_` secret only when the webhook is created. Listing or fetching webhooks later doesn't return it.

**Workarounds:**

* Store the secret in a secrets manager the moment you create the endpoint.
* If it is lost, create a new webhook, deploy its secret, then retire the old one.

**How Hookdeck Can Help:** With Hookdeck in front, only the Hookdeck source needs the Autumn secret, so replacing it is one change in one place.

### Several plan changes in one event

**The Problem:** Code that reads only the first plan change misses the downgrade or upgrade that came with it.

**Why It Happens:** One `billing.updated` event can carry several `plan_changes`. An upgrade from `free` to `pro` arrives as an `activated` entry and an `expired` entry in the same event.

**Workarounds:**

* Loop over every entry in `plan_changes`, and persist each `subscription` snapshot keyed by `customer_id` (and `entity_id` when present).
* Detect cancellations from `previous_attributes.canceled_at`, as Autumn suggests.

**How Hookdeck Can Help:** Hookdeck keeps each payload, so you can replay real `billing.updated` events against your handler when you change how it reads `plan_changes`.

### Retries without a published schedule

**The Problem:** You don't know how long Autumn keeps trying after your endpoint fails.

**Why It Happens:** Autumn says it retries with exponential backoff, but doesn't restate Svix's schedule or say when it gives up.

**Workarounds:**

* Watch the delivery attempts in the Autumn dashboard and retry failed ones from there.
* Return a `2xx` quickly so failures stay rare.

**How Hookdeck Can Help:** Hookdeck accepts each delivery immediately and then applies your own [retry](/docs/retries) policy, with replay for anything that still fails.

## Best practices

### Verify against the raw body

Any change to the body breaks the signature. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in Next.js and `await request.body()` in FastAPI.

### Deduplicate on `svix-id`

Retries keep the same `svix-id`. For top-ups, Autumn also suggests `invoice.stripe_id` as a stable deduplication key. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat the event as a snapshot, not a command

Autumn already keeps billing state in sync. Use webhooks for side effects, and when your own database must match, store the `subscription` snapshots from `billing.updated` rather than replaying actions.

### Acknowledge fast and process asynchronously

Verify, store the event, return `200`, and send emails or provision resources afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Autumn delivers billing events through Svix, signed with `svix-*` headers and a `whsec_` secret that you see only once. Verify against the raw body, loop over every entry in `plan_changes`, deduplicate on `svix-id`, and use the events for side effects rather than as the source of billing truth.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Autumn signature and keeps each event until your service has processed it, so a cancellation never goes unhandled because of an outage on your side.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Autumn webhooks reliably in minutes.