npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@adpena/notifications

npm version license tests zero deps types

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.toml env vars

Install

npm install @adpena/notifications

Or with pnpm / yarn / bun:

pnpm add @adpena/notifications
yarn add @adpena/notifications
bun add @adpena/notifications

Quickstart

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=true

API 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's env, 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) === false land in skipped.
  • Adapters that throw land in failed with the error logged via console.error.
  • If msg.subject or msg.body is 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 enforcementvalidateUrl throws on non-HTTPS for Discord, Slack, WhatsApp.
  • HMAC-SHA256 webhook signing — when WebhookConfig.secret is set, the outbound body is signed and passed in X-Signature. Verify on the receiver by computing the same HMAC.
  • Email CRLF guardisValidEmail rejects \r and \n to prevent header injection into Resend / Cloudflare Email.
  • Markdown stripping — Discord / Slack bodies pass through sanitizeText to drop * _ ~ ``.
  • HTML escaping — Telegram bodies pass through escapeHtml because the bot uses parse_mode: "HTML".
  • Body truncation — all messages capped at 2000 chars with a [truncated] marker.
  • Per-adapter timeout — every adapter gets its own AbortController with a 5-second timeout, so no upstream can hang the dispatcher.
  • Dry-run mode — set DRY_RUN=true to log payload shapes to console.info instead of sending. Secrets are never logged.
  • Zero dependencies — no transitive supply-chain surface.
  • Lockfile committedpackage-lock.json is 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 skipisConfigured is 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 in ctx.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 typecheck

Use the package locally in another project

cd /path/to/notifications
npm link

cd /path/to/your/app
npm link @adpena/notifications

Try 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 -- test

Examples

Runnable examples in examples/:

Testing

npm test          # vitest run (109 tests)
npm run typecheck # tsc --noEmit

Coverage: 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.