Guide to beehiiv Webhooks: Features and Best Practices
Webhooks from beehiiv, the newsletter platform, tell your systems when readers subscribe, confirm, upgrade, pause or leave, when a post is scheduled or sent, when a survey response comes in, and when a podcast episode is published. They are how new subscribers reach your CRM, paid upgrades reach your billing or access control, and sent posts reach your social or analytics tools without polling the API.
This guide covers how beehiiv webhook endpoints are created and signed, the events and their payloads, what beehiiv doesn't document, and the practices that keep a beehiiv receiver reliable.
What are beehiiv webhooks?
A beehiiv webhook is an endpoint URL on a publication plus the event types it should receive. When one of those events happens, beehiiv sends a JSON POST to the URL. Webhooks are only available on paid plans, Lite and above.
The delivery runs through Svix. In beehiiv's words, each request "includes svix-id, svix-timestamp, and svix-signature headers that you should verify with your endpoint's signing secret before processing the payload". The signing secret is on the endpoint's details page in the app.
beehiiv webhook features
| Feature | Details |
|---|---|
| Availability | Paid plans, Lite and above |
| Configuration | Settings > Webhooks, or POST /v2/publications/{publicationId}/webhooks |
| Subscription | event_types per endpoint |
| Payload | { "uid", "event_ |
| 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 | The endpoint's signing secret, shown in the app on the endpoint details page |
| No-code options | beehiiv's own Zapier and Make integrations |
| Retries, timeout and source IPs | Not published |
Common events
The Create webhook API accepts 26 event types:
| Area | Event types |
|---|---|
| Posts | post.sent, post.updated, post.scheduled |
| Subscriptions | subscription.created, subscription.confirmed, subscription.deleted, subscription.upgraded, subscription.downgraded, subscription.paused, subscription.resumed |
| Subscription tiers | subscription.tier.created, subscription.tier.deleted, subscription.tier.paused, subscription.tier.resumed |
| Newsletter lists | newsletter_, newsletter_, newsletter_, newsletter_ |
| Surveys | survey.response_ |
| Podcasts | podcasts.episode.published, podcasts.episode.updated, podcasts.episode.archived, podcasts.episode.deleted, podcasts.private_, podcasts.private_, podcasts.private_ |
Every payload has the same four top-level fields:
| Field | Description |
|---|---|
uid | The prefixed event ID, unique to each webhook event |
event_type | The event type. Route on it |
event_timestamp | When the event was created, in seconds since the Unix epoch |
data | The resource the event is about |
For subscription events, data is a subscription: id, email, status (validating, invalid, pending, active, inactive, needs_attention, paused or unidentified), created, subscription_tier, subscription_, referral_code, referring_site, stripe_customer_id, the utm_* acquisition fields, custom_fields and tags. For post events, it is a post with id, title, subject_line, slug, status, platform, authors, content_tags and other display fields.
beehiiv documents each event as a field schema rather than an example body, and marks most fields as optional. Log a real delivery before relying on a field being present.
Setting up beehiiv webhooks
In the app
- Go to Settings > Webhooks for your publication.
- Create an endpoint with your HTTPS URL and choose the event types.
- Open the endpoint's details page and copy the signing secret. Store it server-side as
BEEHIIV_WEBHOOK_SECRET.
Through the API
curl -X POST "https://api.beehiiv.com/v2/publications/$BEEHIIV_PUBLICATION_ID/webhooks" \
-H "Authorization: Bearer $BEEHIIV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/beehiiv",
"event_types": ["subscription.created", "subscription.upgraded", "post.sent"],
"description": "CRM sync"
}'
OAuth apps need the webhooks:write scope. The API doesn't return the signing secret, so read it from the endpoint details page in the app afterwards.
With Hookdeck. Create a source with the beehiiv source type, paste the signing secret into Webhook Signing Secret exactly as the app shows it, and use the Hookdeck source URL as the endpoint URL. Svix secrets start with whsec_. Hookdeck decodes a whsec_ secret as Svix does, and treats a secret without the prefix as plain text, which wouldn't match. A delivery that fails the check still gets a 200 from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.
Securing beehiiv webhooks
beehiiv points to Svix's manual verification steps. 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.
beehiiv names no timestamp tolerance. The check below is optional and off by default. If you turn it on, remember that a delivery retried or replayed later, for example from Hookdeck, keeps its original timestamp.
const crypto = require('crypto');
const express = require('express');
function verifyBeehiivSignature(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/beehiiv', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyBeehiivSignature(req.body, req.headers, process.env.BEEHIIV_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.uid (or the svix-id header), then route on event.event_type.
res.sendStatus(200);
});
The same check in Python:
import base64
import binascii
import hashlib
import hmac
import time
def verify_beehiiv_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_beehiiv_signature(raw_body, request.headers, os.environ["BEEHIIV_WEBHOOK_SECRET"])
The svix libraries do the same check but always enforce a five-minute timestamp window. A delivery that Hookdeck retries or replays more than five minutes after beehiiv sent it fails the library's check even though its signature is valid. The beehiiv webhook skill includes a manual verifier where you can drop or widen the window.
Make beehiiv webhooks production-ready. Hookdeck Event Gateway verifies the Svix signature on every beehiiv delivery, stores each subscriber and post event, and delivers it to your CRM, billing and analytics services with retries and replay you control.
beehiiv webhook limitations and pain points
Paid plans only
The Problem: The webhooks settings aren't there, or the API refuses to create one.
Why It Happens: beehiiv offers webhooks only to paid publications on the Lite plan and above.
Workarounds:
- Upgrade the publication, or use beehiiv's Zapier or Make integration if it covers what you need.
How Hookdeck Can Help: Once webhooks are on, a single Hookdeck source can fan out each event to several services, so you don't need an endpoint per consumer.
Schemas, not examples
The Problem: Code written from the docs breaks on the first real delivery.
Why It Happens: beehiiv documents each event as a list of fields, most marked optional, with no example body.
Workarounds:
- Send yourself real events, such as a test subscription, and build your parser from them.
- Use optional access for every field in
data.
How Hookdeck Can Help: Hookdeck stores each request with its headers and body, so you can capture one of each event type and replay them against your handler while you develop it.
No published retry policy
The Problem: You don't know whether beehiiv resends an event your endpoint missed, or for how long.
Why It Happens: beehiiv documents no timeout, retry schedule or failure handling for webhooks.
Workarounds:
- Return a
2xxquickly and do the work afterwards. - Reconcile subscribers through beehiiv's API after an outage.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with automatic retries and lets you replay anything that failed.
Subscriber data in the payload
The Problem: Webhook bodies carry subscriber email addresses, custom fields and Stripe customer IDs.
Why It Happens: Subscription events include the subscription record in data.
Workarounds:
- Verify every request, keep the endpoint on HTTPS, and avoid logging full bodies.
- Subscribe each endpoint only to the events it needs.
How Hookdeck Can Help: Hookdeck's transformations can remove fields before an event reaches a destination that doesn't need them.
Best practices
Verify against the raw body
The signature covers the exact bytes beehiiv sent. Use express.raw({ type: '*/*' }) in Express, await request.text() in Next.js and await request.body() in FastAPI, and parse only after verification.
Deduplicate on uid
uid is unique to each webhook event, and svix-id stays the same when Svix resends a message. Store one of them before processing and skip repeats. See our guide to webhook idempotency.
Treat events as notifications for paid access
For upgrades, downgrades and pauses, re-read the subscription from the API before changing someone's access, so an out-of-order event can't undo a newer change.
Acknowledge fast and process asynchronously
Verify, store the event, return 200, and update downstream systems afterwards. See why to process webhooks asynchronously.
Conclusion
beehiiv delivers webhooks through Svix, with svix-* headers and a per-endpoint signing secret found on the endpoint details page. Verify against the raw body with the decoded key, route on event_type, deduplicate on uid, and build your parser from real deliveries, because the docs list fields but show no examples.
Hookdeck Event Gateway verifies each beehiiv signature and keeps every event until your service has processed it, so a new subscriber never goes missing because of an outage on your side.
Get started with Hookdeck for free and handle beehiiv webhooks reliably in minutes.