Gareth Wilson Gareth Wilson

Guide to Autumn Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard (Developer > Webhooks) or POST /v1/webhooks.create
SubscriptionAll events, or a selection. vercel.* events can't be mixed with other events
EnvironmentsWebhooks and secrets are per environment
Endpoint URLHTTPS. Localhost and private-network addresses are rejected
Payload{ "type": "...", "data": { ... } }
Signature headerssvix-id, svix-timestamp, svix-signature
SchemeSvix (Standard Webhooks): HMAC-SHA256, base64, entries prefixed with v1,
Signed content{svix-id}.{svix-timestamp}.{raw body}
Secretwhsec_..., returned once when the webhook is created
TestingSend Test Event in the dashboard
RetriesExponential backoff on errors and unavailability
Delivery logView and retry delivery attempts from the dashboard

Autumn publishes no response timeout or source IP list.

Common events

EventSent when
billing.updatedA customer's plans change: new subscriptions, upgrades, downgrades, cancellations, expiries
balances.limit_reachedA customer hits a usage limit for a feature
balances.usage_alert_triggeredA customer crosses a configured usage alert threshold
billing.auto_topup_succeededAn automatic top-up grants more prepaid balance
billing.auto_topup_failedAn automatic top-up is blocked, declined or fails
invoice.finalizedA Stripe invoice is finalized and its line items reconciled
vercel.resources.provisioned, vercel.resources.deleted, vercel.resources.rotate_secrets, vercel.webhooks.eventVercel 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:

{
  "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

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.

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:

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 includes a manual verifier where you can drop or widen the window.

Make Autumn webhooks production-ready. Hookdeck 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 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.

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.

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 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 for free and handle Autumn webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.