@adpena/notifications
v0.1.1
Published
Multi-channel notification dispatch for Cloudflare Workers — Discord, Slack, Telegram, WhatsApp, Email, Webhooks, Google Sheets, HubSpot, EveryAction, ActionNetwork
Downloads
225
Maintainers
Readme
@adpena/notifications
Multi-channel notification dispatch for Cloudflare Workers, Node.js, Bun, Deno — anywhere with fetch. Zero external dependencies. Built for contact forms, webhook relays, and transactional notifications.
- 19 adapters — Discord, Slack, Telegram, WhatsApp, Resend, Cloudflare Email, Webhooks, Google Sheets, HubSpot Forms, HubSpot Contacts, EveryAction (NGP VAN), ActionNetwork, Meta CAPI, Google Ads Enhanced Conversions, TikTok Events, Twitter/X Conversions, LinkedIn Conversions, Reddit Events, Snapchat CAPI
- Zero dependencies — uses only the Fetch API, Web Crypto, and TypeScript types
- Parallel dispatch with per-adapter timeout (5s default) — one slow channel never blocks the rest
- Never throws — unconfigured adapters are skipped, failures are collected and returned
- Security-first — HTTPS enforcement, HMAC-SHA256 webhook signing, CRLF-safe email validation, markdown/HTML escaping, body truncation
- Cloudflare Workers native — works with Email Workers bindings, secrets, and
wrangler.tomlenv vars
Install
npm install @adpena/notificationsOr with pnpm / yarn / bun:
pnpm add @adpena/notifications
yarn add @adpena/notifications
bun add @adpena/notificationsQuickstart
import { notifyAll } from "@adpena/notifications";
const env = {
DISCORD_WEBHOOK_URL: "https://discord.com/api/webhooks/...",
SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/...",
RESEND_API_KEY: "re_...",
RESEND_FROM_EMAIL: "[email protected]",
RESEND_TO_EMAIL: "[email protected]",
};
const result = await notifyAll(env, {
subject: "New signup",
body: "Ada Lovelace signed up via the homepage.",
fields: { name: "Ada Lovelace", email: "[email protected]" },
});
console.log(result);
// { sent: ["Discord", "Slack", "Resend"], failed: [], skipped: ["Telegram", "WhatsApp", ...] }That's it. Every adapter that has its required env vars set will fire in parallel. Anything not configured is silently skipped. Anything that throws is caught and reported — notifyAll never throws.
Adapters
| Adapter | Required env vars | Transport |
|---|---|---|
| Discord | DISCORD_WEBHOOK_URL | Incoming webhook |
| Slack | SLACK_WEBHOOK_URL | Incoming webhook |
| Telegram | TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID | Bot API |
| WhatsApp | WHATSAPP_API_TOKEN, WHATSAPP_PHONE_ID, WHATSAPP_TO | Meta Graph API v21.0 |
| Resend | RESEND_API_KEY, RESEND_FROM_EMAIL, RESEND_TO_EMAIL | Resend REST API |
| Cloudflare Email | CF_EMAIL_FROM, CF_EMAIL_TO, SEND_EMAIL binding | Workers Email binding |
| HubSpot Forms | HUBSPOT_PORTAL_ID, HUBSPOT_FORM_ID | Public Forms API (no auth) |
| HubSpot Contacts | HUBSPOT_API_TOKEN | CRM v3 (create + upsert on conflict) |
| EveryAction (NGP VAN) | EVERYACTION_API_KEY, EVERYACTION_APP_NAME | People findOrCreate — progressive campaign CRM |
| ActionNetwork | ACTIONNETWORK_API_KEY | OSDI People API — grassroots advocacy CRM |
| Meta CAPI | META_CAPI_PIXEL_ID, META_CAPI_ACCESS_TOKEN | Conversions API v21.0 — server-side ad attribution for Facebook + Instagram (PII SHA-256 hashed) |
| Google Enhanced Conversions | GOOGLE_ADS_CUSTOMER_ID, GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_ADS_ACCESS_TOKEN, GOOGLE_ADS_CONVERSION_ACTION_ID | Google Ads Enhanced Conversions — click conversion upload with hashed PII |
| TikTok Events | TIKTOK_PIXEL_CODE, TIKTOK_ACCESS_TOKEN | Events API v1.3 — server-side ad attribution for TikTok (PII SHA-256 hashed) |
| Twitter/X Conversions | TWITTER_PIXEL_ID, TWITTER_ADS_ACCESS_TOKEN | Conversion API — server-side ad attribution for X/Twitter (PII SHA-256 hashed) |
| LinkedIn Conversions | LINKEDIN_ACCESS_TOKEN, LINKEDIN_CONVERSION_ID, LINKEDIN_AD_ACCOUNT_URN | Conversions API — server-side ad attribution for LinkedIn (PII SHA-256 hashed) |
| Reddit Events | REDDIT_AD_ACCOUNT_ID, REDDIT_ACCESS_TOKEN, REDDIT_PIXEL_ID | Conversion Events API v2 — server-side ad attribution for Reddit (PII SHA-256 hashed) |
| Snapchat CAPI | SNAPCHAT_PIXEL_ID, SNAPCHAT_ACCESS_TOKEN | Conversions API v3 — server-side ad attribution for Snapchat (PII SHA-256 hashed) |
| Webhooks | Configured per call | Generic, HMAC-SHA256, retry |
| Google Sheets | Apps Script web-app URL | Apps Script endpoint |
Per-adapter setup guides live in docs/adapters/.
Environment variables
# Discord
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Slack
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
# Telegram
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
TELEGRAM_CHAT_ID=-1001234567890
# WhatsApp Business
WHATSAPP_API_TOKEN=EAABs...
WHATSAPP_PHONE_ID=1234567890
WHATSAPP_TO=15551234567
# optional override (defaults to https://graph.facebook.com/v21.0)
WHATSAPP_API_URL=https://graph.facebook.com/v21.0
# Resend (email)
RESEND_API_KEY=re_...
[email protected]
[email protected]
# HubSpot Forms (public Forms API, no auth)
HUBSPOT_PORTAL_ID=12345678
HUBSPOT_FORM_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# HubSpot Contacts (private app, crm.objects.contacts.write scope)
HUBSPOT_API_TOKEN=pat-na1-...
# EveryAction / NGP VAN (Bonterra) — POST /people/findOrCreate
EVERYACTION_API_KEY=...
EVERYACTION_APP_NAME=YourAppName
EVERYACTION_MODE=0 # "0" = MyCampaign, "1" = MyVoters (voter file)
# EVERYACTION_BASE_URL=https://api.securevan.com/v4 # optional override
# ActionNetwork — POST https://actionnetwork.org/api/v2/people/
ACTIONNETWORK_API_KEY=...
# Meta Conversions API (Facebook + Instagram)
# POST https://graph.facebook.com/v21.0/{pixel_id}/events
# All PII (email, phone, names, zip, city, state, country) is SHA-256
# hashed via the Web Crypto API before sending. Email/phone are
# normalized first (lowercased / digits-only).
META_CAPI_PIXEL_ID=1234567890
META_CAPI_ACCESS_TOKEN=EAABs...
# Optional: route events to the Test Events tab without affecting live campaigns.
META_CAPI_TEST_EVENT_CODE=TEST12345
# Google Ads Enhanced Conversions
# POST https://googleads.googleapis.com/v17/customers/{id}:uploadClickConversions
# All PII (email, phone, names) is SHA-256 hashed before sending.
GOOGLE_ADS_CUSTOMER_ID=1234567890
GOOGLE_ADS_DEVELOPER_TOKEN=...
GOOGLE_ADS_ACCESS_TOKEN=ya29...
GOOGLE_ADS_CONVERSION_ACTION_ID=987654321
# TikTok Events API
# POST https://business-api.tiktok.com/open_api/v1.3/event/track/
# Email and phone are SHA-256 hashed before sending.
TIKTOK_PIXEL_CODE=CPTEST1234
TIKTOK_ACCESS_TOKEN=...
# Twitter/X Conversions API
# POST https://ads-api.x.com/12/measurement/conversions/{pixel_id}
# Email is SHA-256 hashed before sending.
TWITTER_ADS_ACCESS_TOKEN=...
TWITTER_PIXEL_ID=twpx123456
# LinkedIn Conversions API
# POST https://api.linkedin.com/rest/conversionEvents
# Email is SHA-256 hashed before sending.
LINKEDIN_ACCESS_TOKEN=...
LINKEDIN_CONVERSION_ID=conv123456
LINKEDIN_AD_ACCOUNT_URN=urn:li:sponsoredAccount:12345
# Reddit Conversion Events API
# POST https://ads-api.reddit.com/api/v2.0/conversions/events/{ad_account_id}
# Email is SHA-256 hashed before sending.
REDDIT_AD_ACCOUNT_ID=t2_abcdef
REDDIT_ACCESS_TOKEN=...
REDDIT_PIXEL_ID=rdtpx123456
# Snapchat Conversions API
# POST https://tr.snapchat.com/v3/{pixel_id}/events
# Email and phone are SHA-256 hashed before sending.
SNAPCHAT_PIXEL_ID=snapPx123456
SNAPCHAT_ACCESS_TOKEN=...
# Cloudflare Email Workers
[email protected]
[email protected]
# SEND_EMAIL binding is injected by the Workers runtime via wrangler.toml
# Debug — log payloads instead of sending
DRY_RUN=trueAPI reference
notifyAll(env, msg, adapters?)
function notifyAll(
env: NotifyEnv,
msg: Message,
adapters?: Adapter[],
): Promise<NotifyResult>;Fan out a Message to every configured adapter in parallel. Never throws.
Parameters
env: NotifyEnv— object containing env vars (e.g.process.env, a Worker'senv, or a plain object).msg: Message—{ subject, body, fields?, replyTo? }.adapters?: Adapter[]— optional override. Defaults to all seventeen built-in message adapters (Cloudflare Email, Resend, Discord, Slack, Telegram, WhatsApp, HubSpot Forms, HubSpot Contacts, EveryAction, ActionNetwork, Meta CAPI, Google Enhanced Conversions, TikTok Events, Twitter/X Conversions, LinkedIn Conversions, Reddit Events, Snapchat CAPI).
Returns Promise<NotifyResult> — { sent: string[], failed: string[], skipped: string[] } keyed by adapter name.
Behavior
- Each adapter has a 5-second abort timeout (
ADAPTER_TIMEOUT_MS). - Adapters that return
isConfigured(env) === falseland inskipped. - Adapters that throw land in
failedwith the error logged viaconsole.error. - If
msg.subjectormsg.bodyis empty, nothing is sent and an empty result is returned.
fireWebhooks(callbacks, event, data)
function fireWebhooks(
callbacks: WebhookConfig[] | undefined,
event: string,
data: Record<string, unknown>,
): Promise<void>;Dispatch an event to every webhook whose events array includes event. Uses HMAC-SHA256 signing (if secret is set), JSON or form encoding, and 3 retries with exponential backoff (1s, 5s, 25s). Skips retry on 4xx except 429. Never throws.
import { fireWebhooks } from "@adpena/notifications";
await fireWebhooks(
[
{
url: "https://hooks.zapier.com/hooks/catch/123/abc",
events: ["signup", "purchase"],
format: "json",
secret: "my-shared-secret",
headers: { "X-Source": "crafted" },
},
],
"signup",
{ name: "Ada", email: "[email protected]" },
);WebhookConfig:
interface WebhookConfig {
url: string;
events: string[];
format: "json" | "form";
headers?: Record<string, string>;
secret?: string;
}When secret is set, the request includes X-Signature: <hex HMAC-SHA256 of the raw body>.
sendToSheets(url, data)
function sendToSheets(
url: string,
data: SubmissionData,
): Promise<{ ok: boolean; error?: string }>;POST a flattened JSON row to a Google Apps Script web app. Use APPS_SCRIPT_TEMPLATE for the Apps Script side — paste it into Extensions → Apps Script, deploy as a web app, and pass the web app URL here.
import { sendToSheets, APPS_SCRIPT_TEMPLATE } from "@adpena/notifications";
await sendToSheets("https://script.google.com/macros/s/.../exec", {
type: "signup",
page_slug: "homepage",
timestamp: new Date().toISOString(),
name: "Ada",
email: "[email protected]",
});Nested objects are flattened with dot-notation (address.zip), dates are serialized with toISOString(), arrays are joined with , , and keys are sorted alphabetically for deterministic column order.
Types
interface Message {
subject: string;
body: string;
fields?: Record<string, string>;
replyTo?: string;
}
interface NotifyResult {
sent: string[];
failed: string[];
skipped: string[];
}
interface Adapter {
name: string;
isConfigured: (env: NotifyEnv) => boolean;
send: (env: NotifyEnv, msg: Message, signal: AbortSignal) => Promise<void>;
}Utilities
Exported from the root for direct use:
sanitizeText(text)— strips markdown control chars (*,_,~,`).escapeHtml(text)— escapes& < > ".truncate(text)— truncates to 2000 chars with a[truncated]marker.isValidEmail(email)— RFC-ish check plus CRLF injection guard and length cap.validateUrl(url, label)— throws unless the URL is valid HTTPS.
Architecture
+-----------------------+
| notifyAll(env, msg) |
+-----------+-----------+
|
+------------------+------------------+
| Promise.all (parallel) |
+------------------+-------------------+
|
+-------+-------+-------+-----+-----+-------+-------+-------+--------+
| | | | | | | | |
v v v v v v v v v
Discord Slack Telegram WhatsApp Resend CF Email HS Form HS Contact MetaCAPI
| | | | | | | | |
+-------+-------+-------+-----+-----+-------+-------+-------+--------+
|
5s AbortController
per adapter
|
v
+--------------+--------------+
| { sent, failed, skipped } |
+-----------------------------+
fireWebhooks(configs, event, data)
|
+-> filter by cb.events.includes(event)
+-> sign with HMAC-SHA256 (if secret)
+-> retry: [1s, 5s, 25s] backoff; skip retry on 4xx (except 429)Security
- HTTPS enforcement —
validateUrlthrows on non-HTTPS for Discord, Slack, WhatsApp. - HMAC-SHA256 webhook signing — when
WebhookConfig.secretis set, the outbound body is signed and passed inX-Signature. Verify on the receiver by computing the same HMAC. - Email CRLF guard —
isValidEmailrejects\rand\nto prevent header injection into Resend / Cloudflare Email. - Markdown stripping — Discord / Slack bodies pass through
sanitizeTextto drop* _ ~``. - HTML escaping — Telegram bodies pass through
escapeHtmlbecause the bot usesparse_mode: "HTML". - Body truncation — all messages capped at 2000 chars with a
[truncated]marker. - Per-adapter timeout — every adapter gets its own
AbortControllerwith a 5-second timeout, so no upstream can hang the dispatcher. - Dry-run mode — set
DRY_RUN=trueto log payload shapes toconsole.infoinstead of sending. Secrets are never logged. - Zero dependencies — no transitive supply-chain surface.
- Lockfile committed —
package-lock.jsonis version controlled.
See SECURITY.md for vulnerability reporting and additional details.
Performance
- Parallel dispatch — all adapters run in a single
Promise.all; total latency is bounded by the slowest adapter, not the sum. - Per-adapter 5 s timeout — a hanging channel cannot block siblings.
- Zero allocations on skip —
isConfiguredis a cheap env-var check; unconfigured adapters return immediately. - Fetch-only — no keep-alive pools or SDKs; works inside any V8 isolate, including Cloudflare Workers.
- Never blocks the request — safe to
await notifyAll()in a Workers request handler; wrap inctx.waitUntil(...)if you want to return a response before dispatch completes.
Cloudflare Workers deployment
1. Add env vars and bindings to wrangler.toml
name = "my-contact-form"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[vars]
RESEND_FROM_EMAIL = "[email protected]"
RESEND_TO_EMAIL = "[email protected]"
CF_EMAIL_FROM = "[email protected]"
CF_EMAIL_TO = "[email protected]"
HUBSPOT_PORTAL_ID = "12345678"
HUBSPOT_FORM_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Email Workers binding (optional, zero-cost)
[[send_email]]
name = "SEND_EMAIL"
destination_address = "[email protected]"2. Store secrets via wrangler
wrangler secret put DISCORD_WEBHOOK_URL
wrangler secret put SLACK_WEBHOOK_URL
wrangler secret put TELEGRAM_BOT_TOKEN
wrangler secret put RESEND_API_KEY
wrangler secret put HUBSPOT_API_TOKEN
# etc.3. Use it in a handler
import { notifyAll, type NotifyEnv } from "@adpena/notifications";
export default {
async fetch(req: Request, env: NotifyEnv, ctx: ExecutionContext) {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const body = await req.json<{ name: string; email: string; message: string }>();
// Fire-and-forget: respond immediately, dispatch in the background
ctx.waitUntil(
notifyAll(env, {
subject: `New contact from ${body.name}`,
body: body.message,
fields: { name: body.name, email: body.email },
replyTo: body.email,
}),
);
return Response.json({ ok: true });
},
};A runnable example lives in examples/contact-form.ts.
Local development
git clone https://github.com/adpena/notifications.git
cd notifications
npm install
npm test
npm run typecheckUse the package locally in another project
cd /path/to/notifications
npm link
cd /path/to/your/app
npm link @adpena/notificationsTry the CLI
# Reads env vars and sends a test message to every configured adapter.
npx --package=@adpena/notifications notifications-test
# Or from a clone:
npm run cli -- testExamples
Runnable examples in examples/:
examples/contact-form.ts— Cloudflare Workers contact form that fans out to every configured channel.examples/webhook-relay.ts— accept an incoming webhook, validate it, and re-broadcast viafireWebhooks+notifyAll.examples/admin-script.ts— Node CLI script that sends a test notification to every channel configured inprocess.env.
Testing
npm test # vitest run (109 tests)
npm run typecheck # tsc --noEmitCoverage: dispatch fan-out, sanitization, email validation, URL validation, field formatting, HubSpot Forms and Contacts adapters, EveryAction and ActionNetwork adapters, Meta CAPI adapter (PII hashing, fbc format, DRY_RUN PII safety), Google Enhanced Conversions, TikTok Events, Twitter/X Conversions, LinkedIn Conversions, Reddit Events, Snapchat CAPI (PII hashing, click ID mapping, DRY_RUN, error handling), webhook HMAC signing.
Contributing
See CONTRIBUTING.md. New adapters are welcome — use src/adapters/discord.ts as a template, keep zero dependencies, and add tests.
Changelog
See CHANGELOG.md.
License
MIT — see LICENSE.
