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_ | A customer crosses a configured usage alert threshold |
billing.auto_ | An automatic top-up grants more prepaid balance |
billing.auto_ | 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_, vercel.webhooks.event | Vercel Marketplace events passed through to you |
The create-webhook API also accepts customer.products.updated and customer.threshold_, 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": []
}
}
typeis the event type. Route on it.data.customer_idis your customer ID, and entity-scoped events adddata.entity_id.data.tagscan holdtrial_endedorphase_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_ carries a reason to branch on.
Setting up Autumn webhooks
In the dashboard
- Go to the Developer section and open the Webhooks tab.
- Click Add Endpoint and enter your URL.
- Choose all events or specific ones.
- Save, copy the signing secret straight away, and store it server-side as
AUTUMN_WEBHOOK_SECRET. - 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 eachsubscriptionsnapshot keyed bycustomer_id(andentity_idwhen present). - Detect cancellations from
previous_, as Autumn suggests.attributes.canceled_ at
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
2xxquickly 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.