Guide to Buttondown Webhooks: Features and Best Practices
Buttondown is a newsletter platform for writers and small teams, with paid subscriptions, surveys, automations and integrations with stores and membership tools. Its webhooks fire on a long list of events, from a new subscriber confirming their address to an email finishing its send, a reader answering a survey, or a Stripe subscription becoming active. They are how you keep a CRM, a community platform or an analytics store in step with your newsletter.
This guide covers how Buttondown's webhooks are created, its event families and minimal payloads, the opt-in signing key and X-Buttondown-Signature, and the retry and disable rules a production receiver must respect.
What are Buttondown webhooks?
A Buttondown webhook has a URL, the event types it listens to and, optionally, a signing key. You can select many event types for one webhook. When one of them happens, Buttondown sends a POST with Content-Type: application/json.
The body is deliberately small. It names the event in event_type and puts IDs in data, such as the newsletter and subscriber. To get the subscriber's email address or tags you call the Buttondown API with those IDs.
Signing is off until you set a key. With a key, Buttondown adds X-Buttondown-Signature: sha256=<hex>, an HMAC-SHA256 of the raw body.
Buttondown webhook features
| Feature | Details |
|---|---|
| Configuration | App (Settings, Create webhook) or the Webhooks API |
| Event selection | Several event types per webhook |
| Signature | hex HMAC-SHA256 |
| Signature header | x-buttondown-signature, as sha256=<hex> |
| Secret | Optional signing key you choose; unsigned without it |
| Body | JSON with event_type and data, up to 10 MB |
| Event routing | Body field event_type |
| Success | 2xx only. Redirects, 4xx, 5xx and timeouts fail |
| Retries | Up to 5, about 5 minutes apart |
| Auto-disable | After failures on 5 consecutive events, with an email |
| Ordering | At least once, unordered |
| Source IPs | Not stable, so not usable for allowlisting |
Events and payloads
| Event | Triggered When | Common Use Cases |
|---|---|---|
subscriber.created | A new subscriber is created | Add to your CRM, send a welcome |
subscriber.confirmed | A subscriber confirmed their enrolment (without double opt-in, right after subscriber.created) | Start onboarding |
subscriber.unsubscribed | A subscriber manually unsubscribed | Sync suppression lists |
email.sent | An email finished its delivery | Cross-post, notify a team |
automation.invoked | An automation is manually invoked (for example by a webhook or API call) | Trigger downstream work |
Those five are the usual starting set. Buttondown documents many more:
- Subscriber lifecycle and engagement:
subscriber.paid,subscriber.churned,subscriber.paused,subscriber.resumed,subscriber.trial_started,subscriber.trial_ended,subscriber.bounced,subscriber.complained,subscriber.opened,subscriber.clicked,subscriber.replied,subscriber.commented,subscriber.updated,subscriber.tags.changed,subscriber.referredand thesubscriber.activation_*events for confirmation emails. - Emails:
email.created,email.updated,email.deleted,email.send.startedandemail.status.changed. - Content and admin:
survey.*,form.*,export.*,note.created,mention.created,social_mention.created,advertising_slot.inquiryandadvertising_.slot.purchased - Schedules:
date.day.started,date.week.started,date.month.startedanddate.year.started, fired at midnight UTC. - Integrations: events prefixed
bigcommerce.,memberful.,patreon.,shopify.andstripe., fired when Buttondown syncs data from those services.
The only payload Buttondown publishes is this subscriber.confirmed example, also in the webhook samples library:
{
"event_type": "subscriber.confirmed",
"data": {
"newsletter": "13121cd6-0dfc-424c-bb12-988b0a32fcb3",
"subscriber": "ac79483b-cd28-49c1-982e-8a88e846d7e7"
}
}
The contents of data for other events are not documented, so inspect a real delivery before relying on a field.
Setting up Buttondown webhooks
Set a signing key on the Buttondown webhook. Without one, deliveries are unsigned and fail verification.
- In Buttondown, go to Settings and choose Create webhook, or create one through the Webhooks API.
- Enter your endpoint URL.
- Choose the event types to receive.
- Enter a Signing key. It can be any string, so make it long and random, for example from
openssl rand -hex 32. Buttondown uses it exactly as typed. Store the same value on your server, for example asBUTTONDOWN_SIGNING_KEY. - Save, then click Test webhook to send a test payload.
Automations can call your endpoint too. A send_post_request action signs its request in the same way if you add a signing_key field to the action's metadata.
To receive through Hookdeck, create a source with the Buttondown source type, enter the same signing key as its verification secret, and use the source URL as the webhook URL. If you leave the signing key empty in Buttondown, leave verification off on the source too. Hookdeck CLI users can test locally with npx hookdeck-cli listen 3000 buttondown --path /webhooks/buttondown.
Securing Buttondown webhooks
Strip sha256= from X-Buttondown-Signature, check that the rest is 64 hex characters, and compare it in constant time with an HMAC-SHA256 of the raw body keyed with your signing key. Buttondown's Python example in the webhooks introduction does the same.
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['x-buttondown-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature.startsWith('sha256=') ? signature.slice(7) : signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Because data holds only IDs, a typical handler verifies, then fetches the full record from Buttondown's API:
const express = require('express');
const app = express();
app.post('/webhooks/buttondown', express.raw({ type: '*/*' }), async (req, res) => {
const signingKey = process.env.BUTTONDOWN_SIGNING_KEY;
if (!signingKey) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, signingKey)) return res.sendStatus(401);
let parsed;
try {
parsed = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
const eventType = parsed.event_type;
const data = parsed.data && typeof parsed.data === 'object' ? parsed.data : {};
try {
switch (eventType) {
case 'subscriber.confirmed':
case 'subscriber.paid':
await syncSubscriber(data.subscriber);
break;
case 'subscriber.unsubscribed':
case 'subscriber.churned':
await markInactive(data.subscriber, eventType);
break;
case 'email.sent':
await announceIssue(data);
break;
default:
break;
}
} catch {
return res.sendStatus(503);
}
res.sendStatus(200);
});
syncSubscriber would call the Buttondown API for the subscriber's details. In production, queue that call and return 200 first, because a slow API lookup could otherwise make the delivery fail.
No timestamp is signed, so a captured request remains valid. Hookdeck's Buttondown source verifies the same sha256= HMAC and has no timestamp rule to apply. It acknowledges Buttondown with 200 and records a failed verification afterwards rather than returning 401.
Make Buttondown webhooks production-ready. Hookdeck Event Gateway verifies
X-Buttondown-Signature, stores every subscriber and email event, and delivers it to your service with retries that don't count against Buttondown's disable rule.
Buttondown webhook limitations and pain points
Five bad events and the webhook is off
The Problem: A broken deploy stops all your webhooks for good, not just for the length of the outage.
Why It Happens: Buttondown disables a webhook that fails on 5 consecutive events and sends you an email. On an active newsletter, five events can arrive in seconds.
Workarounds:
- Return a
2xxfor every verified request, including event types you don't handle. - Watch for Buttondown's disable email, and re-enable the webhook as soon as your endpoint is healthy.
How Hookdeck Can Help: Hookdeck accepts each event straight away, so Buttondown sees successful deliveries even while your service is failing. Hookdeck then retries your service on its own schedule.
Thin payloads
The Problem: You get a subscriber ID, not a subscriber, and every event needs an API call to be useful.
Why It Happens: Buttondown keeps data to IDs, and documents only one example payload.
Workarounds:
- Fetch the record from the Buttondown API after you have acknowledged the webhook.
- Capture one delivery of each event type you rely on, and code against what you see.
How Hookdeck Can Help: Hookdeck's request log keeps every payload Buttondown has sent, which makes it easy to see what data contains for each event type.
Unordered, at-least-once delivery
The Problem: subscriber.unsubscribed can arrive before subscriber.confirmed, or arrive twice.
Why It Happens: Buttondown says deliveries are at least once and unordered. Its delivery page tells you to deduplicate on the event's id, but the published example has no id field.
Workarounds:
- Use an
idif your deliveries carry one. Otherwise, read the subscriber's current state from the API instead of trusting the order of events. - Make writes idempotent, such as setting a status rather than toggling it.
How Hookdeck Can Help: Hookdeck's deduplication can drop identical requests, and its delivery log shows exactly what arrived and when.
Signing is optional
The Problem: A webhook created without a signing key sends unsigned requests, which your verifier rejects or, worse, skips.
Why It Happens: The signing key field is optional, and Buttondown sends no signature header without it.
Workarounds:
- Always set a key, and fail closed in your receiver when the header is missing.
How Hookdeck Can Help: With verification on, Hookdeck marks unsigned Buttondown requests as failed verification, so a missing key is visible in the request log.
Best practices
Choose events per webhook
Create separate webhooks for unrelated consumers, such as one for subscriber sync and one for email publishing, so a problem in one consumer doesn't disable the other's webhook.
Use the raw body
Verify before parsing. Use express.raw({ type: '*/*' }) in Express, await request.arrayBuffer() in Next.js and await request.body() in FastAPI.
Respond with 2xx quickly
Buttondown documents no timeout, so don't depend on a generous one. Store the event, answer, and process it afterwards. The reasoning is in our asynchronous processing article.
Make handlers idempotent
Retries and unordered delivery mean the same change can arrive more than once, or late. Our guide to webhook idempotency covers the patterns.
Don't allowlist IPs
Buttondown delivers from changing addresses. Authenticate with the signature only.
Conclusion
Buttondown covers nearly everything a newsletter does with webhooks, but each payload is only an event type and a few IDs, and signing waits until you set a key. Set the key, verify the sha256= signature against the raw body, fetch details from the API after responding, and keep every response a fast 2xx, because five failed events switch the webhook off.
Hookdeck Event Gateway receives Buttondown's events, checks their signatures, and retries delivery to your service without putting the webhook at risk.
Get started with Hookdeck for free and handle Buttondown webhooks reliably in minutes.