# 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_type", "event_timestamp", "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 | 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_list_subscription.subscribed`, `newsletter_list_subscription.unsubscribed`, `newsletter_list_subscription.paused`, `newsletter_list_subscription.resumed` |
| Surveys | `survey.response_submitted` |
| Podcasts | `podcasts.episode.published`, `podcasts.episode.updated`, `podcasts.episode.archived`, `podcasts.episode.deleted`, `podcasts.private_feed.activated`, `podcasts.private_feed.access_revoked`, `podcasts.private_feed.deactivated` |

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_premium_tier_names`, `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

1. Go to **Settings > Webhooks** for your publication.
2. Create an endpoint with your HTTPS URL and choose the event types.
3. Open the endpoint's details page and copy the signing secret. Store it server-side as `BEEHIIV_WEBHOOK_SECRET`.

### Through the API

```bash
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.

```javascript
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:

```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](https://github.com/hookdeck/webhook-skills/tree/main/skills/beehiiv-webhooks) includes a manual verifier where you can drop or widen the window.

> **Make beehiiv webhooks production-ready.** [Hookdeck Event Gateway](/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](/docs/connections) 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 `2xx` quickly 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](/docs/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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle beehiiv webhooks reliably in minutes.