{"_id":"@adpena/notifications","_rev":"2-098f89336ee87587fdb44bd4e755fa69","name":"@adpena/notifications","dist-tags":{"latest":"0.1.1"},"versions":{"0.1.0":{"name":"@adpena/notifications","version":"0.1.0","keywords":["notifications","cloudflare-workers","discord","slack","telegram","whatsapp","resend","hubspot","everyaction","actionnetwork","webhooks","google-sheets","zero-dependencies"],"license":"MIT","_id":"@adpena/notifications@0.1.0","maintainers":[{"name":"adpena","email":"adpena@gmail.com"}],"homepage":"https://github.com/adpena/notifications#readme","bugs":{"url":"https://github.com/adpena/notifications/issues"},"bin":{"notifications-test":"bin/notifications-test.mjs"},"dist":{"shasum":"617d68483777f958fa94253e7cf45fa96cc999e6","tarball":"https://registry.npmjs.org/@adpena/notifications/-/notifications-0.1.0.tgz","fileCount":55,"integrity":"sha512-VMvVcMaUQE7x7LbK60Uu0OIxFBRaNPgSEjRuqwnWx5gXZi0WdWtVTAYAN5hUmLt/vo+ezYDZ2KPNBHcDuizRzg==","signatures":[{"sig":"MEQCIEmYUPH4Jr+JGtel9go0XDKQhNcmGBaaJghNcChv0B6oAiANKE3vGE51RP694mAMZ0lYtPO1rbvg4bKuF5auToOT8A==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":171044},"main":"src/index.ts","type":"module","types":"src/index.ts","engines":{"node":">=20"},"exports":{".":"./src/index.ts","./cli":"./src/cli.ts","./sheets":"./src/sheets.ts","./webhooks":"./src/webhooks.ts","./adapters/*":"./src/adapters/*.ts"},"gitHead":"239fb94c9f9a4152f95fb4876bdf79dc8fc2482a","scripts":{"cli":"node ./bin/notifications-test.mjs","test":"vitest run","typecheck":"tsc --noEmit"},"_npmUser":{"name":"adpena","email":"adpena@gmail.com"},"repository":{"url":"git+https://github.com/adpena/notifications.git","type":"git"},"_npmVersion":"11.12.1","description":"Multi-channel notification dispatch for Cloudflare Workers — Discord, Slack, Telegram, WhatsApp, Email, Webhooks, Google Sheets, HubSpot, EveryAction, ActionNetwork","directories":{},"_nodeVersion":"25.9.0","_hasShrinkwrap":false,"devDependencies":{"vitest":"^4.1.0","typescript":"^5.8.0","@types/node":"^25.5.2"},"_npmOperationalInternal":{"tmp":"tmp/notifications_0.1.0_1775598443724_0.1821108665702782","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@adpena/notifications","version":"0.1.1","description":"Multi-channel notification dispatch for Cloudflare Workers — Discord, Slack, Telegram, WhatsApp, Email, Webhooks, Google Sheets, HubSpot, EveryAction, ActionNetwork","type":"module","main":"src/index.ts","types":"src/index.ts","exports":{".":"./src/index.ts","./adapters/*":"./src/adapters/*.ts","./webhooks":"./src/webhooks.ts","./sheets":"./src/sheets.ts","./cli":"./src/cli.ts"},"bin":{"notifications-test":"bin/notifications-test.mjs"},"scripts":{"test":"vitest run","typecheck":"tsc --noEmit","cli":"node ./bin/notifications-test.mjs"},"devDependencies":{"@types/node":"^25.5.2","typescript":"^5.8.0","vitest":"^4.1.0"},"keywords":["notifications","cloudflare-workers","discord","slack","telegram","whatsapp","resend","hubspot","everyaction","actionnetwork","webhooks","google-sheets","zero-dependencies"],"repository":{"type":"git","url":"git+https://github.com/adpena/notifications.git"},"homepage":"https://github.com/adpena/notifications#readme","bugs":{"url":"https://github.com/adpena/notifications/issues"},"engines":{"node":">=20"},"license":"MIT","gitHead":"69cc02784f9918dc69f85abf1fa1632523e69fa7","_id":"@adpena/notifications@0.1.1","_nodeVersion":"25.9.0","_npmVersion":"11.12.1","dist":{"integrity":"sha512-Kk4H2K2b+0IdNcw4KFuM8R7XDqIfHNClV1lyVBAtAgpgyVjDcIbMucHcsJtYYarvoyWe9CqRR4VkEohNY+mDZw==","shasum":"21e7b0014506652fc6461a5a852a5ab3be7fcf54","tarball":"https://registry.npmjs.org/@adpena/notifications/-/notifications-0.1.1.tgz","fileCount":55,"unpackedSize":170991,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIHWO+HefQI4OG25bzrymJ8DL3HiKlIc6zAS4b1PqR+DeAiBwQBFKxSDLIS8d63yVC85JHrJsEv5Am5lvpNo2nu0MTg=="}]},"_npmUser":{"name":"adpena","email":"adpena@gmail.com"},"directories":{},"maintainers":[{"name":"adpena","email":"adpena@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/notifications_0.1.1_1775599805158_0.728948338552752"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-07T21:47:23.653Z","modified":"2026-04-07T22:10:05.482Z","0.1.0":"2026-04-07T21:47:23.857Z","0.1.1":"2026-04-07T22:10:05.344Z"},"bugs":{"url":"https://github.com/adpena/notifications/issues"},"license":"MIT","homepage":"https://github.com/adpena/notifications#readme","keywords":["notifications","cloudflare-workers","discord","slack","telegram","whatsapp","resend","hubspot","everyaction","actionnetwork","webhooks","google-sheets","zero-dependencies"],"repository":{"type":"git","url":"git+https://github.com/adpena/notifications.git"},"description":"Multi-channel notification dispatch for Cloudflare Workers — Discord, Slack, Telegram, WhatsApp, Email, Webhooks, Google Sheets, HubSpot, EveryAction, ActionNetwork","maintainers":[{"name":"adpena","email":"adpena@gmail.com"}],"readme":"# @adpena/notifications\n\n[![npm version](https://img.shields.io/npm/v/@adpena/notifications.svg)](https://www.npmjs.com/package/@adpena/notifications)\n[![license](https://img.shields.io/npm/l/@adpena/notifications.svg)](./LICENSE)\n[![tests](https://img.shields.io/badge/tests-109%20passing-brightgreen.svg)](#testing)\n[![zero deps](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](./package.json)\n[![types](https://img.shields.io/badge/types-TypeScript-blue.svg)](./src/index.ts)\n\nMulti-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.\n\n- **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\n- **Zero dependencies** — uses only the Fetch API, Web Crypto, and TypeScript types\n- **Parallel dispatch** with per-adapter timeout (5s default) — one slow channel never blocks the rest\n- **Never throws** — unconfigured adapters are skipped, failures are collected and returned\n- **Security-first** — HTTPS enforcement, HMAC-SHA256 webhook signing, CRLF-safe email validation, markdown/HTML escaping, body truncation\n- **Cloudflare Workers native** — works with Email Workers bindings, secrets, and `wrangler.toml` env vars\n\n## Install\n\n```bash\nnpm install @adpena/notifications\n```\n\nOr with pnpm / yarn / bun:\n\n```bash\npnpm add @adpena/notifications\nyarn add @adpena/notifications\nbun add @adpena/notifications\n```\n\n## Quickstart\n\n```typescript\nimport { notifyAll } from \"@adpena/notifications\";\n\nconst env = {\n  DISCORD_WEBHOOK_URL: \"https://discord.com/api/webhooks/...\",\n  SLACK_WEBHOOK_URL: \"https://hooks.slack.com/services/...\",\n  RESEND_API_KEY: \"re_...\",\n  RESEND_FROM_EMAIL: \"notifications@yourdomain.com\",\n  RESEND_TO_EMAIL: \"you@yourdomain.com\",\n};\n\nconst result = await notifyAll(env, {\n  subject: \"New signup\",\n  body: \"Ada Lovelace signed up via the homepage.\",\n  fields: { name: \"Ada Lovelace\", email: \"ada@example.com\" },\n});\n\nconsole.log(result);\n// { sent: [\"Discord\", \"Slack\", \"Resend\"], failed: [], skipped: [\"Telegram\", \"WhatsApp\", ...] }\n```\n\nThat'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.\n\n## Adapters\n\n| Adapter | Required env vars | Transport |\n|---|---|---|\n| **Discord** | `DISCORD_WEBHOOK_URL` | Incoming webhook |\n| **Slack** | `SLACK_WEBHOOK_URL` | Incoming webhook |\n| **Telegram** | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | Bot API |\n| **WhatsApp** | `WHATSAPP_API_TOKEN`, `WHATSAPP_PHONE_ID`, `WHATSAPP_TO` | Meta Graph API v21.0 |\n| **Resend** | `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, `RESEND_TO_EMAIL` | Resend REST API |\n| **Cloudflare Email** | `CF_EMAIL_FROM`, `CF_EMAIL_TO`, `SEND_EMAIL` binding | Workers Email binding |\n| **HubSpot Forms** | `HUBSPOT_PORTAL_ID`, `HUBSPOT_FORM_ID` | Public Forms API (no auth) |\n| **HubSpot Contacts** | `HUBSPOT_API_TOKEN` | CRM v3 (create + upsert on conflict) |\n| **EveryAction (NGP VAN)** | `EVERYACTION_API_KEY`, `EVERYACTION_APP_NAME` | People `findOrCreate` — progressive campaign CRM |\n| **ActionNetwork** | `ACTIONNETWORK_API_KEY` | OSDI People API — grassroots advocacy CRM |\n| **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) |\n| **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 |\n| **TikTok Events** | `TIKTOK_PIXEL_CODE`, `TIKTOK_ACCESS_TOKEN` | Events API v1.3 — server-side ad attribution for TikTok (PII SHA-256 hashed) |\n| **Twitter/X Conversions** | `TWITTER_PIXEL_ID`, `TWITTER_ADS_ACCESS_TOKEN` | Conversion API — server-side ad attribution for X/Twitter (PII SHA-256 hashed) |\n| **LinkedIn Conversions** | `LINKEDIN_ACCESS_TOKEN`, `LINKEDIN_CONVERSION_ID`, `LINKEDIN_AD_ACCOUNT_URN` | Conversions API — server-side ad attribution for LinkedIn (PII SHA-256 hashed) |\n| **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) |\n| **Snapchat CAPI** | `SNAPCHAT_PIXEL_ID`, `SNAPCHAT_ACCESS_TOKEN` | Conversions API v3 — server-side ad attribution for Snapchat (PII SHA-256 hashed) |\n| **Webhooks** | Configured per call | Generic, HMAC-SHA256, retry |\n| **Google Sheets** | Apps Script web-app URL | Apps Script endpoint |\n\nPer-adapter setup guides live in [`docs/adapters/`](./docs/adapters/).\n\n### Environment variables\n\n```bash\n# Discord\nDISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...\n\n# Slack\nSLACK_WEBHOOK_URL=https://hooks.slack.com/services/...\n\n# Telegram\nTELEGRAM_BOT_TOKEN=123456:ABC-DEF...\nTELEGRAM_CHAT_ID=-1001234567890\n\n# WhatsApp Business\nWHATSAPP_API_TOKEN=EAABs...\nWHATSAPP_PHONE_ID=1234567890\nWHATSAPP_TO=15551234567\n# optional override (defaults to https://graph.facebook.com/v21.0)\nWHATSAPP_API_URL=https://graph.facebook.com/v21.0\n\n# Resend (email)\nRESEND_API_KEY=re_...\nRESEND_FROM_EMAIL=notifications@yourdomain.com\nRESEND_TO_EMAIL=you@yourdomain.com\n\n# HubSpot Forms (public Forms API, no auth)\nHUBSPOT_PORTAL_ID=12345678\nHUBSPOT_FORM_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\n\n# HubSpot Contacts (private app, crm.objects.contacts.write scope)\nHUBSPOT_API_TOKEN=pat-na1-...\n\n# EveryAction / NGP VAN (Bonterra) — POST /people/findOrCreate\nEVERYACTION_API_KEY=...\nEVERYACTION_APP_NAME=YourAppName\nEVERYACTION_MODE=0                # \"0\" = MyCampaign, \"1\" = MyVoters (voter file)\n# EVERYACTION_BASE_URL=https://api.securevan.com/v4   # optional override\n\n# ActionNetwork — POST https://actionnetwork.org/api/v2/people/\nACTIONNETWORK_API_KEY=...\n\n# Meta Conversions API (Facebook + Instagram)\n# POST https://graph.facebook.com/v21.0/{pixel_id}/events\n# All PII (email, phone, names, zip, city, state, country) is SHA-256\n# hashed via the Web Crypto API before sending. Email/phone are\n# normalized first (lowercased / digits-only).\nMETA_CAPI_PIXEL_ID=1234567890\nMETA_CAPI_ACCESS_TOKEN=EAABs...\n# Optional: route events to the Test Events tab without affecting live campaigns.\nMETA_CAPI_TEST_EVENT_CODE=TEST12345\n\n# Google Ads Enhanced Conversions\n# POST https://googleads.googleapis.com/v17/customers/{id}:uploadClickConversions\n# All PII (email, phone, names) is SHA-256 hashed before sending.\nGOOGLE_ADS_CUSTOMER_ID=1234567890\nGOOGLE_ADS_DEVELOPER_TOKEN=...\nGOOGLE_ADS_ACCESS_TOKEN=ya29...\nGOOGLE_ADS_CONVERSION_ACTION_ID=987654321\n\n# TikTok Events API\n# POST https://business-api.tiktok.com/open_api/v1.3/event/track/\n# Email and phone are SHA-256 hashed before sending.\nTIKTOK_PIXEL_CODE=CPTEST1234\nTIKTOK_ACCESS_TOKEN=...\n\n# Twitter/X Conversions API\n# POST https://ads-api.x.com/12/measurement/conversions/{pixel_id}\n# Email is SHA-256 hashed before sending.\nTWITTER_ADS_ACCESS_TOKEN=...\nTWITTER_PIXEL_ID=twpx123456\n\n# LinkedIn Conversions API\n# POST https://api.linkedin.com/rest/conversionEvents\n# Email is SHA-256 hashed before sending.\nLINKEDIN_ACCESS_TOKEN=...\nLINKEDIN_CONVERSION_ID=conv123456\nLINKEDIN_AD_ACCOUNT_URN=urn:li:sponsoredAccount:12345\n\n# Reddit Conversion Events API\n# POST https://ads-api.reddit.com/api/v2.0/conversions/events/{ad_account_id}\n# Email is SHA-256 hashed before sending.\nREDDIT_AD_ACCOUNT_ID=t2_abcdef\nREDDIT_ACCESS_TOKEN=...\nREDDIT_PIXEL_ID=rdtpx123456\n\n# Snapchat Conversions API\n# POST https://tr.snapchat.com/v3/{pixel_id}/events\n# Email and phone are SHA-256 hashed before sending.\nSNAPCHAT_PIXEL_ID=snapPx123456\nSNAPCHAT_ACCESS_TOKEN=...\n\n# Cloudflare Email Workers\nCF_EMAIL_FROM=notifications@yourdomain.com\nCF_EMAIL_TO=you@yourdomain.com\n# SEND_EMAIL binding is injected by the Workers runtime via wrangler.toml\n\n# Debug — log payloads instead of sending\nDRY_RUN=true\n```\n\n## API reference\n\n### `notifyAll(env, msg, adapters?)`\n\n```typescript\nfunction notifyAll(\n  env: NotifyEnv,\n  msg: Message,\n  adapters?: Adapter[],\n): Promise<NotifyResult>;\n```\n\nFan out a `Message` to every configured adapter in parallel. Never throws.\n\n**Parameters**\n- `env: NotifyEnv` — object containing env vars (e.g. `process.env`, a Worker's `env`, or a plain object).\n- `msg: Message` — `{ subject, body, fields?, replyTo? }`.\n- `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).\n\n**Returns** `Promise<NotifyResult>` — `{ sent: string[], failed: string[], skipped: string[] }` keyed by adapter name.\n\n**Behavior**\n- Each adapter has a 5-second abort timeout (`ADAPTER_TIMEOUT_MS`).\n- Adapters that return `isConfigured(env) === false` land in `skipped`.\n- Adapters that throw land in `failed` with the error logged via `console.error`.\n- If `msg.subject` or `msg.body` is empty, nothing is sent and an empty result is returned.\n\n### `fireWebhooks(callbacks, event, data)`\n\n```typescript\nfunction fireWebhooks(\n  callbacks: WebhookConfig[] | undefined,\n  event: string,\n  data: Record<string, unknown>,\n): Promise<void>;\n```\n\nDispatch 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.\n\n```typescript\nimport { fireWebhooks } from \"@adpena/notifications\";\n\nawait fireWebhooks(\n  [\n    {\n      url: \"https://hooks.zapier.com/hooks/catch/123/abc\",\n      events: [\"signup\", \"purchase\"],\n      format: \"json\",\n      secret: \"my-shared-secret\",\n      headers: { \"X-Source\": \"crafted\" },\n    },\n  ],\n  \"signup\",\n  { name: \"Ada\", email: \"ada@example.com\" },\n);\n```\n\n`WebhookConfig`:\n```typescript\ninterface WebhookConfig {\n  url: string;\n  events: string[];\n  format: \"json\" | \"form\";\n  headers?: Record<string, string>;\n  secret?: string;\n}\n```\n\nWhen `secret` is set, the request includes `X-Signature: <hex HMAC-SHA256 of the raw body>`.\n\n### `sendToSheets(url, data)`\n\n```typescript\nfunction sendToSheets(\n  url: string,\n  data: SubmissionData,\n): Promise<{ ok: boolean; error?: string }>;\n```\n\nPOST 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.\n\n```typescript\nimport { sendToSheets, APPS_SCRIPT_TEMPLATE } from \"@adpena/notifications\";\n\nawait sendToSheets(\"https://script.google.com/macros/s/.../exec\", {\n  type: \"signup\",\n  page_slug: \"homepage\",\n  timestamp: new Date().toISOString(),\n  name: \"Ada\",\n  email: \"ada@example.com\",\n});\n```\n\nNested 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.\n\n### Types\n\n```typescript\ninterface Message {\n  subject: string;\n  body: string;\n  fields?: Record<string, string>;\n  replyTo?: string;\n}\n\ninterface NotifyResult {\n  sent: string[];\n  failed: string[];\n  skipped: string[];\n}\n\ninterface Adapter {\n  name: string;\n  isConfigured: (env: NotifyEnv) => boolean;\n  send: (env: NotifyEnv, msg: Message, signal: AbortSignal) => Promise<void>;\n}\n```\n\n### Utilities\n\nExported from the root for direct use:\n\n- `sanitizeText(text)` — strips markdown control chars (`*`, `_`, `~`, `` ` ``).\n- `escapeHtml(text)` — escapes `& < > \"`.\n- `truncate(text)` — truncates to 2000 chars with a `[truncated]` marker.\n- `isValidEmail(email)` — RFC-ish check plus CRLF injection guard and length cap.\n- `validateUrl(url, label)` — throws unless the URL is valid HTTPS.\n\n## Architecture\n\n```\n                    +-----------------------+\n                    |   notifyAll(env, msg) |\n                    +-----------+-----------+\n                                |\n             +------------------+------------------+\n             |           Promise.all (parallel)     |\n             +------------------+-------------------+\n                                |\n  +-------+-------+-------+-----+-----+-------+-------+-------+--------+\n  |       |       |       |           |       |       |       |        |\n  v       v       v       v           v       v       v       v        v\nDiscord Slack Telegram WhatsApp   Resend  CF Email HS Form HS Contact MetaCAPI\n  |       |       |       |           |       |       |       |        |\n  +-------+-------+-------+-----+-----+-------+-------+-------+--------+\n                                |\n                         5s AbortController\n                         per adapter\n                                |\n                                v\n                 +--------------+--------------+\n                 |  { sent, failed, skipped }  |\n                 +-----------------------------+\n\nfireWebhooks(configs, event, data)\n  |\n  +-> filter by cb.events.includes(event)\n  +-> sign with HMAC-SHA256 (if secret)\n  +-> retry: [1s, 5s, 25s] backoff; skip retry on 4xx (except 429)\n```\n\n## Security\n\n- **HTTPS enforcement** — `validateUrl` throws on non-HTTPS for Discord, Slack, WhatsApp.\n- **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.\n- **Email CRLF guard** — `isValidEmail` rejects `\\r` and `\\n` to prevent header injection into Resend / Cloudflare Email.\n- **Markdown stripping** — Discord / Slack bodies pass through `sanitizeText` to drop `* _ ~ ` ``.\n- **HTML escaping** — Telegram bodies pass through `escapeHtml` because the bot uses `parse_mode: \"HTML\"`.\n- **Body truncation** — all messages capped at 2000 chars with a `[truncated]` marker.\n- **Per-adapter timeout** — every adapter gets its own `AbortController` with a 5-second timeout, so no upstream can hang the dispatcher.\n- **Dry-run mode** — set `DRY_RUN=true` to log payload shapes to `console.info` instead of sending. Secrets are never logged.\n- **Zero dependencies** — no transitive supply-chain surface.\n- **Lockfile committed** — `package-lock.json` is version controlled.\n\nSee [SECURITY.md](./SECURITY.md) for vulnerability reporting and additional details.\n\n## Performance\n\n- **Parallel dispatch** — all adapters run in a single `Promise.all`; total latency is bounded by the slowest adapter, not the sum.\n- **Per-adapter 5 s timeout** — a hanging channel cannot block siblings.\n- **Zero allocations on skip** — `isConfigured` is a cheap env-var check; unconfigured adapters return immediately.\n- **Fetch-only** — no keep-alive pools or SDKs; works inside any V8 isolate, including Cloudflare Workers.\n- **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.\n\n## Cloudflare Workers deployment\n\n### 1. Add env vars and bindings to `wrangler.toml`\n\n```toml\nname = \"my-contact-form\"\nmain = \"src/index.ts\"\ncompatibility_date = \"2025-01-01\"\n\n[vars]\nRESEND_FROM_EMAIL = \"notifications@yourdomain.com\"\nRESEND_TO_EMAIL = \"you@yourdomain.com\"\nCF_EMAIL_FROM = \"notifications@yourdomain.com\"\nCF_EMAIL_TO = \"you@yourdomain.com\"\nHUBSPOT_PORTAL_ID = \"12345678\"\nHUBSPOT_FORM_ID = \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\"\n\n# Email Workers binding (optional, zero-cost)\n[[send_email]]\nname = \"SEND_EMAIL\"\ndestination_address = \"you@yourdomain.com\"\n```\n\n### 2. Store secrets via wrangler\n\n```bash\nwrangler secret put DISCORD_WEBHOOK_URL\nwrangler secret put SLACK_WEBHOOK_URL\nwrangler secret put TELEGRAM_BOT_TOKEN\nwrangler secret put RESEND_API_KEY\nwrangler secret put HUBSPOT_API_TOKEN\n# etc.\n```\n\n### 3. Use it in a handler\n\n```typescript\nimport { notifyAll, type NotifyEnv } from \"@adpena/notifications\";\n\nexport default {\n  async fetch(req: Request, env: NotifyEnv, ctx: ExecutionContext) {\n    if (req.method !== \"POST\") return new Response(\"Method not allowed\", { status: 405 });\n    const body = await req.json<{ name: string; email: string; message: string }>();\n\n    // Fire-and-forget: respond immediately, dispatch in the background\n    ctx.waitUntil(\n      notifyAll(env, {\n        subject: `New contact from ${body.name}`,\n        body: body.message,\n        fields: { name: body.name, email: body.email },\n        replyTo: body.email,\n      }),\n    );\n\n    return Response.json({ ok: true });\n  },\n};\n```\n\nA runnable example lives in [`examples/contact-form.ts`](./examples/contact-form.ts).\n\n## Local development\n\n```bash\ngit clone https://github.com/adpena/notifications.git\ncd notifications\nnpm install\nnpm test\nnpm run typecheck\n```\n\n### Use the package locally in another project\n\n```bash\ncd /path/to/notifications\nnpm link\n\ncd /path/to/your/app\nnpm link @adpena/notifications\n```\n\n### Try the CLI\n\n```bash\n# Reads env vars and sends a test message to every configured adapter.\nnpx --package=@adpena/notifications notifications-test\n\n# Or from a clone:\nnpm run cli -- test\n```\n\n## Examples\n\nRunnable examples in [`examples/`](./examples/):\n\n- [`examples/contact-form.ts`](./examples/contact-form.ts) — Cloudflare Workers contact form that fans out to every configured channel.\n- [`examples/webhook-relay.ts`](./examples/webhook-relay.ts) — accept an incoming webhook, validate it, and re-broadcast via `fireWebhooks` + `notifyAll`.\n- [`examples/admin-script.ts`](./examples/admin-script.ts) — Node CLI script that sends a test notification to every channel configured in `process.env`.\n\n## Testing\n\n```bash\nnpm test          # vitest run (109 tests)\nnpm run typecheck # tsc --noEmit\n```\n\nCoverage: 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.\n\n## Contributing\n\nSee [CONTRIBUTING.md](./CONTRIBUTING.md). New adapters are welcome — use [`src/adapters/discord.ts`](./src/adapters/discord.ts) as a template, keep zero dependencies, and add tests.\n\n## Changelog\n\nSee [CHANGELOG.md](./CHANGELOG.md).\n\n## License\n\nMIT — see [LICENSE](./LICENSE).\n","readmeFilename":"README.md"}