Block disposable emails in ConvertKit (Kit) & Beehiiv with a webhook
Block disposable ConvertKit (Kit) and Beehiiv subscribers right after signup with a webhook, then tag or unsubscribe automatically. No official app needed.
Last updated July 25, 2026. Kit's signing algorithm, Beehiiv's lack of a signing secret, and Beehiiv's webhook plan gate are all vendor behavior worth reconfirming if it's been a while since this date.
Why disposable subscribers cost more than fake signups
A fake signup in a SaaS app wastes a seat. A fake subscriber on a newsletter is worse: most list platforms bill by subscriber count, and a pile of disposable addresses that never open or click drags down the sender reputation your real readers depend on for inbox placement. See how fake subscribers show up in your numbers for the full breakdown.
There's no Kit or Beehiiv app for disposable email checks
To be upfront: neither Kit (formerly ConvertKit) nor Beehiiv has an official Mailsurity app or plugin in their integration directories. What follows is a general integration pattern built on a feature both platforms already ship, an outgoing subscriber webhook, wired to a small serverless function you host yourself. It takes about the same effort as installing a real app, but it's worth knowing going in that you're wiring up the plumbing yourself, including the verification step, which neither platform hands you for free. Both walkthroughs below use the official Mailsurity npm SDK; install it once with npm install mailsurity and see that guide for API-key setup if you haven't already.
ConvertKit (Kit): catch disposable signups in a Visual Automation
Kit's Visual Automations can run a Webhook action at any step, POSTing the subscriber's profile to a URL you control. Kit's help center confirms the action can sign that request with a shared secret you set in its own settings panel, specifically so you can verify the call is genuinely from Kit before you act on it.
Step 1: build the automation and grab the webhook URL
In Kit, go to Automations → New Automation, start it on Subscribes to a form (or whichever trigger feeds your list), then add a Webhook action and paste in your endpoint's URL. Open the action's own settings and turn on the shared secret; copy the value into KIT_WEBHOOK_SECRET and note the header name Kit shows you next to it, since that part isn't documented anywhere you can copy-paste from.
Step 2: verify the signature, then tag and let a second rule remove it
Your endpoint compares the shared secret against the request using HMAC-SHA256 over a hex digest, the convention most webhook senders use. Kit's docs confirm the secret exists but not the algorithm, so treat that as a best guess and confirm it with a real Test Webhook before you trust it in production. Once a request checks out, the route checks the email with Mailsurity, and if it's disposable, calls Kit's v4 API to tag the subscriber by email address. A second, plain Kit automation rule (Tagged: disposable → Unsubscribe) handles the actual removal, so your webhook code only has to call the one documented tag endpoint. Calling it twice on an already-tagged subscriber is harmless.
import { createHmac, timingSafeEqual } from 'crypto';
import { Mailsurity } from 'mailsurity';
const ms = new Mailsurity({ apiKey: process.env.MAILSURITY_API_KEY! });
const DISPOSABLE_TAG_ID = process.env.KIT_DISPOSABLE_TAG_ID!;
const KIT_WEBHOOK_SECRET = process.env.KIT_WEBHOOK_SECRET!;
// Kit's action settings panel shows the exact signature header the moment
// you add the shared secret. That header name isn't documented anywhere
// else, so read it from env instead of hardcoding a guess.
const KIT_SIGNATURE_HEADER = process.env.KIT_SIGNATURE_HEADER!;
// Kit confirms the shared secret exists but doesn't document the signing
// algorithm or encoding. HMAC-SHA256 over a hex digest is the
// best-documented guess (Stripe, GitHub, and Shopify all use it) — confirm
// it against a real Test Webhook before trusting this in production.
function isGenuinelyFromKit(rawBody: string, signature: string | null) {
if (!signature) return false;
const expected = createHmac('sha256', KIT_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(req: Request) {
// Always verify before trusting the body. Read the raw text first, since
// HMAC has to run over the exact bytes Kit sent, not the re-serialized JSON.
const rawBody = await req.text();
if (!isGenuinelyFromKit(rawBody, req.headers.get(KIT_SIGNATURE_HEADER))) {
return new Response('Invalid signature', { status: 400 });
}
// Kit's automation webhook ships the subscriber's profile as JSON; read
// defensively and confirm the exact key with the payload preview Kit shows
// you while you're setting the action up.
const payload = JSON.parse(rawBody);
const email = payload.subscriber?.email_address ?? payload.email_address;
if (!email) return Response.json({ ok: true });
if (await ms.isDisposable(email)) {
// Tag it, then let a second Kit automation rule
// ("Tagged: disposable -> Unsubscribe") do the removal. That way this
// route only ever needs the one, well-documented "tag a subscriber"
// call, not a guessed-at unsubscribe endpoint.
await fetch(`https://api.kit.com/v4/tags/${DISPOSABLE_TAG_ID}/subscribers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Kit-Api-Key': process.env.KIT_API_KEY!,
},
body: JSON.stringify({ email_address: email }),
});
}
return Response.json({ ok: true });
}Beehiiv: catch disposable signups with a dashboard webhook
Beehiiv's webhooks are currently only available for paid users on the Scale plan and above, not any paid tier, so confirm your plan before you build this. Unlike Kit, Beehiiv doesn't document a payload-signing secret at all. Its create-webhook API response doesn't include a signing secret, so there's nothing to verify an inbound call against the way Kit's shared secret works.
That plan requirement is Beehiiv's own gating, separate from what checking each subscriber against Mailsurity costs. See current plans and credits if you want the numbers up front before you build either webhook.
Step 1: create the webhook and lock the URL down
Go to Settings → Webhooks → New Endpoint and paste in your route's URL, including a long random token as its own path segment (something like /api/webhooks/beehiiv/8f2c…). Since there's no signature to check, that token is what stands in for one: the route below 404s any request whose token doesn't match, so a guessed or scraped URL can't trigger it. Select the Subscription Created event, save, then send yourself a Test Webhook to confirm it 200s.
Step 2: check the domain and unsubscribe through Beehiiv's API
Subscription Created fires the moment someone signs up, before they even confirm a double opt-in, so you can pull a disposable address before it ever shows up as a real number in your dashboard. If you run double opt-in and would rather wait for a confirmed subscriber, pick Subscription Confirmed instead; the code below is the same either way, just swap the event-type check. Once the domain checks out as disposable, the route looks up the subscription by email and updates it with unsubscribe: true, a second, authenticated call to Beehiiv's own API rather than anything trusted straight from the payload.
import { timingSafeEqual } from 'crypto';
import { Mailsurity } from 'mailsurity';
const ms = new Mailsurity({ apiKey: process.env.MAILSURITY_API_KEY! });
const PUBLICATION_ID = process.env.BEEHIIV_PUBLICATION_ID!;
const BEEHIIV_API_KEY = process.env.BEEHIIV_API_KEY!;
// Beehiiv doesn't publish a payload-signing secret for webhooks — its
// create-webhook API response has no secret field, so there's nothing to
// verify against the way Kit's shared secret works. Use an unguessable URL
// instead: this route's [token] segment must match a random value only you
// and Beehiiv's dashboard know. Compare it the same constant-time way the
// Kit sample compares its HMAC signature, rather than `!==`, so a guessed
// token can't be brute-forced by timing the 404 response.
function tokenMatches(candidate: string) {
const expected = process.env.BEEHIIV_WEBHOOK_TOKEN!;
const a = Buffer.from(candidate);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(
req: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const { token } = await params;
if (!tokenMatches(token)) {
return new Response('Not found', { status: 404 });
}
const payload = await req.json();
if (payload.type !== 'subscription.created') return Response.json({ ok: true });
const email = payload.data?.email;
if (!email || !(await ms.isDisposable(email))) {
return Response.json({ ok: true });
}
// Look up the subscription id for this email, then unsubscribe it. This
// re-authenticates through Beehiiv's own API with your API key rather than
// trusting anything else in the payload, so a forged POST can at most
// trigger a real lookup, not an arbitrary account change.
const lookup = await fetch(
`https://api.beehiiv.com/v2/publications/${PUBLICATION_ID}/subscriptions/by_email/${encodeURIComponent(email)}`,
{ headers: { Authorization: `Bearer ${BEEHIIV_API_KEY}` } },
);
// A retried delivery can arrive after the subscription was already
// removed, so a 404 here is expected, not an error. Treat "nothing to
// unsubscribe" as success instead of throwing on a missing id.
if (!lookup.ok) return Response.json({ ok: true });
const { data: subscription } = await lookup.json();
if (!subscription?.id) return Response.json({ ok: true });
await fetch(
`https://api.beehiiv.com/v2/publications/${PUBLICATION_ID}/subscriptions/${subscription.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${BEEHIIV_API_KEY}`,
},
body: JSON.stringify({ unsubscribe: true }),
},
);
return Response.json({ ok: true });
}Handle retries without double-processing
Most webhook senders retry a delivery that times out or comes back with anything other than a 2xx, so treat both routes above as things that might run twice for the same subscriber. They already are: tagging a Kit subscriber who's already tagged, and unsubscribing a Beehiiv subscriber who's already unsubscribed, are both no-ops on the receiving end. If you add anything beyond tag/unsubscribe later, keep that property rather than assuming a webhook only ever fires once.
What Kit and Beehiiv already do about disposable subscribers
Both platforms already do real list hygiene. Kit ships a cold-subscriber filter and reCAPTCHA on its forms; Beehiiv keeps a suppression list and can run inactivity-based re-engagement automations that mark stale subscribers inactive on their own. None of that touches whether an address was ever a real, deliverable inbox in the first place. A disposable address can clear reCAPTCHA (it proves a human submitted the form, it says nothing about whether the address itself is real or deliverable), confirm a double opt-in (the confirmation email really was received, just in a throwaway inbox), and sit unbounced for weeks before the account expires. The whole time it's counted, mailed to, and not obviously different from a real subscriber. That's the specific gap a disposable-email check closes, not a replacement for either platform's own hygiene tools.
This removes disposable subscribers, it doesn't block them
Be clear-eyed about the one real limitation here: unlike the Next.js signup guide or the Clerk and Auth0 guides, neither Kit nor Beehiiv gives a third party a hook that runs before the subscriber is created. Their webhooks are notifications, not gatekeepers. So this pattern creates the subscriber, then removes them a moment later, rather than rejecting the form submission outright. In practice that's a few-second window, not a real gap, but if you own the signup form yourself, check the email with Mailsurity before you ever call Kit or Beehiiv's subscribe API and skip the round trip entirely.
The point: your list only counts real readers, not disposable ones
Every check above runs through the same detection Mailsurity uses on any signup: your own allow/deny rules first, then a known-legitimate list, then a running list of 220k+ disposable domains, then a cached verdict if we've seen the domain before, and, only for the rare domain nobody's checked yet, live heuristics and an AI model to break the tie. See how the detection works for the full breakdown. Paste your current subscriber export into the free audit to see how many are already disposable before you wire up either webhook.
FAQ
Does Kit (formerly ConvertKit) actually sign its webhook payloads, or is the shared secret just for show?
It's real. Kit's help center confirms the shared secret exists specifically to verify a webhook call came from Kit and not a forged POST. What it doesn't publish is the header name or the signing algorithm. See Step 2 of the ConvertKit (Kit) walkthrough above for exactly what's confirmed versus what you still need to verify yourself with a Test Webhook before shipping it.
Why doesn't the Beehiiv sample verify a signature the way the Kit one does?
Because Beehiiv doesn't currently document one. Its webhook-creation API response has no signing secret field. See the Beehiiv walkthrough above for the unguessable-URL substitute this sample uses instead.
What Beehiiv plan do I need to use webhooks?
Beehiiv's developer docs are specific here: webhooks are available on Beehiiv's own Scale plan and above (a paid Beehiiv tier, not to be confused with Mailsurity's own Scale plan), not just any paid tier. Check your Beehiiv plan before you build this. Lower tiers can't create a webhook endpoint at all.
Will a subscriber get tagged or unsubscribed twice if Kit or Beehiiv retries the webhook?
It shouldn't. Both routes above are safe to run twice on the same subscriber. Tagging an already-tagged Kit subscriber and unsubscribing an already-unsubscribed Beehiiv one are both no-ops. That matters because most webhook senders retry a delivery that times out or doesn't return a 2xx.
Don't Kit and Beehiiv already filter out fake subscribers?
Partially. Kit's cold-subscriber filter and form-level reCAPTCHA catch inactivity and bots, and Beehiiv's suppression list and inactivity-based re-engagement automations catch stale addresses after the fact. Neither checks whether an address was ever a real, deliverable inbox at the moment it signs up. That's the specific gap this webhook closes.
See how many disposable signups slip past your current setup
Paste a list of your signups. Mailsurity shows how many a standard blocklist misses that we catch by mail-server fingerprint. No signup needed; the part before the @ never leaves your browser.
Need the full field and error reference? See the API reference.