Automation is for everyone, from half-asleep parents to the overcaffeinated marketer. I’ve come to learn this through my own readers. The audiences on Automation Almanac are completely different — and automatically tagging Beehiiv subscribers based on where they came from is how I keep them sorted.
One audience found me through a meal-planning article I wrote; they came from social media, a friend's recommendation, or simply because they want their lives to be a little less chaotic. The other audience found me through LinkedIn.
Both groups are exactly the right audience for Automation Almanac. Still, someone who wants a better meal-planning process might not want an article on using Claude to categorize job titles. The fix isn't creating another newsletter. It's using the content people come from to sort them into content they'll actually use.
Adding more options to a signup form increases friction, which, in some cases, is actually fine. But we're clever here at Automation Almanac, and we like to automate things.
How Beehiiv Signals Who Your Subscriber is
When someone clicks a subscribe button on a Beehiiv article, Beehiiv records which article they came from referring_site in their webhook. The URL looks something like
https://www.automation-almanac.com/p/google-sheets-meal-planner-with-automatic-grocery-listThe slug after /p/ is the article identifier. If you’ve tagged your articles in Beehiiv with business or lifestyle, you can look up those tags via the Beehiiv API using that slug.
So the logic is:
Subscribers sign up via an article
Webhook fires with the subscribers
referring_siteExtract the slug from the URL
Look up the article's content tags
Write the matching track back to the subscriber
For people who subscribe from your main page (no article context), there is no referring URL. For those, I added a subscription-type dropdown to the signup form — a fallback custom field they can self-select. The function uses this when the article lookup comes up empty.
Setting Up Beehiiv First
Before the function does anything, Beehiiv needs to be configured—three steps, lots of clicking around.
Tag your articles
Open each of your Beehiiv posts and add a content tag you want to track. This is the data the function reads. For a backlog of a dozen articles, it may take 5–10 minutes.
Add The Custom Fields
In your Beehiiv dashboard, go to Subscribers → Custom Fields. Create a custom field for subscription type. You can also link it to a preference type.

Set up your webhooks.
Go to Settings → Webhooks and create a new webhook that fires on subscriber.created. Paste your Firebase function URL once it's deployed. Beehiiv will start sending a POST request to that URL every time someone new subscribes.

That’s it for the Beehiiv side. Now the Firebase function.
The Code
The full project is on GitHub. Here’s how the pieces work. You can use auto-deploy and follow the tutorial on GitHub to get it set up. This will set up the project on Google Firebase and get all the code working for you. You may need to change your content tags to ones that fit your publication.
Verifying the Webhook
Beehiiv uses Svix to deliver webhooks, so the signing scheme is Svix. Each request comes with one of three headers: svix-is, svix-timestamp, svix-signature. The signed content is the concatenation of those three values, separated by dots. The secret is base64-encoded with a whsec_ A prefix that you strip before you use it.
const crypto = require("crypto")
function verifySignature(rawBody, headers, secret) {
const svixId = headers["svix-id"]
const svixTimestamp = headers["svix-timestamp"]
const svixSignature = headers["svix-signature"]
if (!svixId || !svixTimestamp || !svixSignature || !secret) return false
const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
const expected = crypto
.createHmac("sha256", secretBytes)
.update(signedContent)
.digest("base64")
// svix-signature may contain multiple space-separated signatures: "v1,<sig1> v1,<sig2>"
const signatures = svixSignature.split(" ")
return signatures.some(sig => {
const [, sigValue] = sig.split(",")
if (!sigValue) return false
try {
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(sigValue),
)
} catch {
return false
}
})
}The svix-signature header can carry multiple signatures, so the code checks if any of them match. If none do, the function returns a 401 error and stops. You don't want an arbitrary POST request writing tags to your subscribers
Detect the track
This is the core logic: check the referring URL first. If it’s an article, look up the post’s tags. If not, check the custom field.
async function detectTrack(payload, getPostTagsFn) {
const { referring_site, custom_fields } = payload
// Path 1: subscriber came from an article
if (referring_site && referring_site.includes("/p/")) {
const slug = extractSlug(referring_site) // pulls the slug from /p/<slug>
if (slug) {
const contentTags = await getPostTagsFn(slug)
const track = tagsToTrack(contentTags) // maps ["work"] → "work", etc.
if (track) return track
}
}
// Path 2: fallback to custom field
const trackPref = getCustomFieldValue(custom_fields, "subscription_type")
if (trackPref) {
const normalized = trackPref.toLowerCase()
if (["business", "lifestyle"].includes(normalized)) return normalized
}
return null // no track detected
}Applying the Track
Once the track is detected, write it back to the subscriber via the Beehiiv API:
async function applyTrack(subscriptionId, track, apiKey, pubId) {
const url = `https://api.beehiiv.com/v2/publications/${pubId}/subscriptions/${subscriptionId}`
const options = {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
custom_fields: [{ name: "track", value: track }],
}),
}
try {
const response = await fetch(url, options)
if (!response.ok) throw new Error(`Beehiiv API error ${response.status}`)
return response.json()
} catch (err) {
// retry once — Beehiiv's API occasionally hiccups
const response = await fetch(url, options)
if (!response.ok) throw new Error(`Beehiiv API error ${response.status}`)
return response.json()
}
}There is a single retry because Beehiiv’s API might return a 5xx error on the first attempt and succeed immediately on the second—just a cheap fail-safe.
Wiring It Together
The main function handler ties everything together. It verifies the signature, detects the track, and applies it.
exports.tagSubscriber = onRequest(async (req, res) => {
// verify
if (
!verifySignature(
req.rawBody,
req.headers["x-beehiiv-signature"],
BEEHIIV_WEBHOOK_SECRET,
)
) {
return res.status(401).send("Unauthorized")
}
const subscription = req.body.data || req.body
const { id: subscriptionId, referring_site, custom_fields } = subscription
// detect
const track = await detectTrack(
{ referring_site, custom_fields: custom_fields || [] },
slug => getPostTags(slug, BEEHIIV_API_KEY, BEEHIIV_PUBLICATION_ID),
)
if (!track) {
return res
.status(200)
.json({ subscriber_id: subscriptionId, tagged: false })
}
// apply
await applyTrack(
subscriptionId,
track,
BEEHIIV_API_KEY,
BEEHIIV_PUBLICATION_ID,
)
return res.status(200).json({ subscriber_id: subscriptionId, track })
})Gotchas
Use req.rawBody for the HMAC, not req.body.
Firebase parses the request body before your function sees it. If you compute the HMAC from JSON.stringify(req.body), it won't match Beehiiv's signature — whitespace and key ordering will differ from the original bytes. Firebase v2 gives you req.rawBody for exactly this reason. The code warns if it's unavailable and falls back, but if you're seeing 401s on valid webhooks, this is the first thing to check.
You won't know the exact payload shape until the first live delivery
Beehiiv's documentation shows a single payload structure, but the actual webhook may nest the subscription under event.data or send it at the top level, depending on the event type. The code handles both req.body.data || req.body, but there's a comment index.js reminding you to log the first live delivery and confirm the shape before removing the safety net.
The slug lookup can silently return nothing.
If the post isn't found — whether it's a draft, deleted, or has the wrong slug format — the function doesn't throw. It falls through to the custom field check instead. This is intentional: you don't want a bad slug breaking the entire signup flow. But it does mean some subscribers might come through untagged if articles aren't tagged yet in Beehiiv. Tag your articles before you point the webhook at the function.
What's Next
Every subscriber now gets tagged automatically. As long as I add the correct content tag to articles, we’ll be able to track which audience segments we're reaching, target each segment with a different welcome flow, and see whether engagement changes between segments.