A stateless notification relay for Cloudflare Workers and Node. Senders POST a message; the relay authenticates them, validates the message and delivers it to the channels named in the URL, within the same request. There is no queue, no database and no history.
- Delivers, never stores. Anything stateful (deduplication, grouping, acknowledgement, escalation, dead-man's switches) belongs upstream or in a service built for it, e.g. your alerting system.
- The sender picks the channels. They are part of the URL. The relay has no routing rules and no default channel.
- The sender owns the content. Channels put each field in its slot and add nothing; the relay never joins, converts or strips text. A sender writing for a constrained channel controls every character that arrives.
- Adapters on both sides. Inbound adapters turn a sender's native payload into one message format; outbound adapters turn that message into a channel's API call. The core knows neither.
- Not a pager. If something must wake you up, send it to a paging service directly. A relay outage should cost you routine messages, not an incident.
inbound adapters message outbound adapters
/webhook ──→ Message ──→ deliver ──→ loki, smtp, gmail
POST /webhook?channels=loki HTTP/1.1
Authorization: Bearer nr_...
Content-Type: application/json
{"title": "deploy done", "level": "low"}The URL says where to deliver and the body says what the message is. Every inbound endpoint works this way; for an adapter that translates a third-party payload, the body is that payload unchanged.
| Query | Required | Meaning |
|---|---|---|
channels |
yes | Primary group: names of configured channels, comma-separated, delivered in parallel |
fallback |
no | Tried in order, only when the whole primary group failed, until one succeeds |
A channel that is not configured, a channel in both groups or a missing channels is a 400. Channel
names are the deployment's own (see Channels), not channel types.
/webhook takes the relay's own format:
| Field | Required | Meaning |
|---|---|---|
title |
yes | One line |
text |
no | Plain text |
rich |
no | Formatted text in the channel's own markup: HTML for smtp and gmail, Markdown where a channel renders it |
level |
no | low, normal (default), high or critical. How loudly each channel notifies, never which channels |
url |
no | A link back to the source; https only |
key |
no | On channels that support it, a new message with the same key replaces the previous one |
group |
no | Display grouping |
to |
no | Recipients by channel name, e.g. {"mail": "ops@example.com"}; see Recipients |
title, text and rich are the content. A channel that renders markup shows rich when it is given,
the others show text; the relay neither checks that the two agree nor derives one from the other.
level, url, key and group are metadata, used by the channels that have a place for them and
ignored by the rest; each channel type below says which it uses. to is checked as
Recipients describes. An empty text, rich, key or group counts as left out.
Unknown fields are a 400, so a misspelled field fails at the sender instead of being dropped. Bodies over 64 KB are a 413.
A channel delivers to its configured recipients (TO on mail channels) unless to names others for it:
{"title": "invoice paid", "to": {"mail": "Jane <jane@example.com>"}}Each entry must name a channel in channels or fallback, of a type that takes recipients (smtp,
gmail), written in that type's syntax and admitted by the channel's ALLOW_TO; otherwise the request is
a 400 and nothing is delivered. A channel without an entry, a fallback included, uses its own recipients.
Any sender token can reach whoever ALLOW_TO admits, so keep it narrow.
| Status | Meaning |
|---|---|
| 200 | At least one primary channel succeeded, or the primary group failed and a fallback succeeded |
| 204 | The inbound adapter produced no message (e.g. an empty alert group) |
| 400, 401, 404, 405, 413 | The request is wrong; retrying will not help |
| 502 | Every channel failed; retry |
200 and 502 list the outcome per channel, without any secret:
{"messages": [{"delivered": ["loki"], "failed": [{"channel": "bark", "error": "timeout"}]}]}A 400 body is {"error": "<reason>"}. A 401 has no body, only WWW-Authenticate: Bearer. Delivery is
at-least-once: an occasional duplicate is preferred to a silent loss, and key lets channels collapse
duplicates.
Each channel attempt times out after 5 seconds, enforced by the relay rather than left to the adapter.
A 429 whose Retry-After is a number of seconds no greater than 2 is retried once within the request.
A missing Retry-After, an HTTP date or a longer wait counts as a failure rather than blocking the
request; the caller retries.
GET /healthz needs no token and calls no channel. It answers 200 when at least one channel is
configured, every configured channel has a known type and all of that type's required parameters, and at
least one source token exists; 503 otherwise, without saying what is missing. Channel types nobody
configured are not checked. Deploys do not check settings (see Platforms), so point an
external probe here.
Authorization: Bearer <token>, one token per sender, stored as a secret named TOKEN_<SOURCE>. A
token in TOKEN_GITHUB authenticates the source github (lowercase, _ becomes -). The source comes
only from the token, never from the request, and adding a sender means adding a secret, not changing
code.
- A secret may hold several tokens separated by spaces, so a rotation can overlap.
- Tokens are compared in constant time against every candidate. Failures are a bare 401.
- Tokens are never accepted in the query string, which ends up in logs.
Generate a token with the nr_ prefix, which makes it easy for secret scanners to recognise:
printf 'nr_%s\n' "$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')"A channel type is code (loki); a channel is a configured instance of one, named by the deployment.
A deployment can configure several channels of the same type and none of another. Each channel is a set
of settings, which are Worker bindings or environment variables depending on the platform:
CHANNEL_<NAME>_TYPE the type, e.g. loki; declares the channel
CHANNEL_<NAME>_<PARAM> one per parameter of that type; optional ones may be left out
The channel's name is <NAME> in lowercase with _ turned into -, so CHANNEL_HOME_LAB_TYPE declares
home-lab, used as ?channels=home-lab. Parameters are looked up by their exact names. The relay reads
variables and secrets alike; keep the sensitive parameters as secrets.
CHANNEL_LOGS_TYPE=loki # variable
CHANNEL_LOGS_URL=https://logs-prod-000.grafana.net/loki/api/v1/push
CHANNEL_LOGS_USER=123456
CHANNEL_LOGS_TOKEN=... # secret
CHANNEL_ARCHIVE_TYPE=loki # a second channel of the same type
...
Pushes each message as one log line to Loki's /loki/api/v1/push with basic auth. Labels hold only
low-cardinality fields (job="notify-relay", source, level); every other field is in the line's JSON
as sent. Handy as a first channel: a line you can query is a positive signal that delivery works, and it
doubles as a record of everything sent.
| Parameter | Keep as | Meaning |
|---|---|---|
URL |
variable | e.g. https://logs-prod-000.grafana.net/loki/api/v1/push |
USER |
variable | Basic auth user (the Loki instance ID on Grafana Cloud) |
TOKEN |
secret | Basic auth password; on Grafana Cloud, an access policy token with logs:write only |
Submits an email to an SMTP server, a mailbox provider's or a sending service's. title is the subject,
text the plain-text body (the title again when there is none), and rich, when given, the HTML
alternative. url, key, group and level are not used: a link belongs in the content.
Each message opens its own connection and authenticates with AUTH PLAIN over TLS: implicit on port 465,
STARTTLS on any other. A server that refuses STARTTLS fails the delivery.
| Parameter | Keep as | Meaning |
|---|---|---|
HOST |
variable | The submission server, e.g. smtp.example.com |
PORT |
variable, optional | Defaults to 587. Workers cannot connect to port 25 |
USER |
variable or secret | The SMTP user name |
PASSWORD |
secret | Its password |
FROM |
variable | The sender; the server must let USER send as it |
TO |
variable or secret | The recipients unless to names others for this channel |
ALLOW_TO |
variable, optional | Recipients a message may name in to: *, *@example.com (not its subdomains) or addresses, comma-separated. Without it, none |
TO and recipients in to are one address or several, comma-separated, and FROM exactly one; each is
address or Name <address>, with a name holding a comma in double quotes. Names may be in any script;
addresses are ASCII, so an internationalised domain goes in its xn-- form. A rejected SMTP step fails
delivery as e.g. SMTP 535 on AUTH, without the server's text, which can quote an address.
With a mailbox provider, send from an account of its own: an app password usually opens the whole mailbox over IMAP too, not only sending.
Sends an email through the Gmail API, as the Google account that granted the refresh token. The mail is
the one smtp sends. Each message first exchanges the refresh token for an access token; nothing is
cached.
| Parameter | Keep as | Meaning |
|---|---|---|
CLIENT_ID |
variable | OAuth client of type Desktop app |
CLIENT_SECRET |
secret | That client's secret |
REFRESH_TOKEN |
secret | Granted with the https://www.googleapis.com/auth/gmail.send scope only |
TO |
variable or secret | The recipients unless to names others for this channel |
FROM |
variable, optional | The sender's name, as Name <address>. Without it, Gmail uses the account's own name |
ALLOW_TO |
variable, optional | Which recipients a message may name in to. Without it, none |
TO, FROM and ALLOW_TO work as for smtp. The FROM address must be the account's own or one of its
verified Send mail as aliases.
Getting the refresh token, once:
- Use a dedicated Google account for sending, so a runaway sender hits its daily limit, not yours.
- In Google Cloud, enable the Gmail API, add
gmail.sendunder the OAuth consent screen's data access, and publish the app to production: in testing, refresh tokens expire after 7 days. An unverified app works for its owner; the consent screen shows a warning. - Run the authorization-code flow with a loopback redirect (
http://127.0.0.1:<port>),access_type=offlineandprompt=consent, then exchange the code for the refresh token.
The token stays valid until the grant is revoked, the account's password changes, or it goes unused for
six months. A dead token fails delivery with refresh token revoked or expired (invalid_grant);
/healthz cannot see it, since the parameters are still set.
The relay is the same on every platform: the same endpoints, settings and channels.
wrangler.jsonc holds nothing deployment-specific. Variables are passed at deploy time and secrets are
set on the Worker:
npm ci
npx wrangler deploy --var CHANNEL_LOGS_TYPE:loki --var CHANNEL_LOGS_URL:https://... --var CHANNEL_LOGS_USER:123456
npx wrangler secret put CHANNEL_LOGS_TOKEN
npx wrangler secret put TOKEN_GITHUB- wrangler removes variables a deploy does not mention, so pass every
--varon every deploy (or keep them in a config of your own). - Secrets cannot be set on a Worker that does not exist yet, which is why none are listed under
secrets.required: the first deploy succeeds, then/healthzstays 503 until the secrets are in. workers_devand preview URLs are off and no routes are declared. Attach a custom domain yourself (dashboard, Terraform, orroutesin your own config); deploys then leave it alone.
An HTTP server on Node 24 or later, built into one file with no dependencies:
npm ci
npm run build:node
PORT=8080 TOKEN_GITHUB=nr_... CHANNEL_LOGS_TYPE=loki CHANNEL_LOGS_URL=https://... ... node dist/node/index.mjs- Settings are environment variables with the same names as on Workers. Every
TOKEN_*variable is a sender token and everyCHANNEL_*_TYPEdeclares a channel, so keep other variables of those shapes out of the process's environment. PORTis where it listens, 8080 by default.SIGTERMorSIGINTstops new connections; requests in flight finish first.- Unlike a Worker, it can reach hosts on a private network, and port 25 where the host allows it.
The Node server in an image of its own: node:24-alpine and the one bundled file, run as node, not
root. It runs wherever containers do, such as Cloud Run, Fly.io, Railway, Render, ECS or Kubernetes.
Each commit on main that passes CI is published for linux/amd64 and linux/arm64 as
ghcr.io/therank/notify-relay:sha-<commit>, and as latest while it is the newest:
docker run --publish 8080:8080 --env-file relay.env ghcr.io/therank/notify-relay:latestrelay.envholds the settings, oneNAME=valueper line. Docker takes each value as written, quotes included.latestmoves with every merge; pinsha-<commit>where a deploy must be repeatable.- To build it yourself:
docker build --tag notify-relay .. The base image is pinned by digest, and Dependabot proposes its updates weekly. - Its
HEALTHCHECKprobes/healthz, so a relay that is not ready shows as unhealthy. A platform that probes on its own can point at/healthzas well.
Cloud Run deploys public GHCR images directly. A Cloud Run function is a Cloud Run service built from source and billed the same way, so on Google Cloud the image is also the serverless deploy:
gcloud run deploy notify-relay --image ghcr.io/therank/notify-relay:sha-<commit> \
--region <region> --allow-unauthenticated --env-vars-file relay.env \
--startup-probe httpGet.path=/healthz- Settings come from the same
relay.envasdocker run, withoutPORT, which Cloud Run sets itself. gcloud reads a file ending in.envwith python-dotenv, which, unlike Docker, drops quotes and a trailing#comment and expands${NAME}; plain values mean the same to both. --allow-unauthenticatedleaves authentication to the relay's tokens.- Cloud Run ignores the image's
HEALTHCHECKand by default only waits for the port to open. The startup probe holds a revision back until/healthzanswers 200, so a deploy with incomplete settings fails instead of taking traffic. - Each revision keeps the image it was deployed with, and Cloud Run caches GHCR images for up to an hour,
so deploy a
sha-<commit>. For higher availability, Google recommends pulling through an Artifact Registry remote repository. - Google Cloud blocks port 25. A private network is reachable through Direct VPC egress or a connector.
A function behind a Function URL or an HTTP API, which send events in payload format 2.0. REST APIs send 1.0, which the relay answers with a 500.
npm ci
npm run build:lambda
(cd dist/lambda && zip ../lambda.zip index.mjs)
aws lambda create-function --function-name notify-relay --runtime nodejs24.x --handler index.handler \
--zip-file fileb://dist/lambda.zip --role <execution role ARN> \
--timeout 30 --environment file://env.json
aws lambda create-function-url-config --function-name notify-relay --auth-type NONE
aws lambda add-permission --function-name notify-relay --statement-id function-url \
--action lambda:InvokeFunctionUrl --principal '*' --function-url-auth-type NONE
aws lambda add-permission --function-name notify-relay --statement-id function-invoke \
--action lambda:InvokeFunction --principal '*' --invoked-via-function-url- Settings are the function's environment variables, as on Node:
env.jsonis{"Variables": {"TOKEN_GITHUB": "nr_...", ...}}. Lambda allows 4 KB of them in all. --auth-type NONEleaves authentication to the relay's tokens; withAWS_IAM, senders would have to sign their requests. The twoadd-permissionstatements are the resource policy a public URL needs, which the console creates by itself; without either, the URL answers 403.- The timeout must cover the slowest delivery: up to 5 seconds per channel attempt, with fallbacks tried one after another. Lambda's default of 3 seconds is too short.
- A function reaches a private network only from inside a VPC, and AWS blocks port 25 unless asked.
An HTTP function on the Node.js v4 programming model, in the Flex Consumption plan. npm run build:azure
writes the deploy package to dist/azure: the bundle, which holds @azure/functions, and the
host.json and package.json the host reads.
npm ci
npm run build:azure
(cd dist/azure && zip ../azure.zip host.json package.json index.js)
az functionapp create --name <app> --resource-group <group> --storage-account <account> \
--flexconsumption-location <region> --runtime node --runtime-version 24
az functionapp config appsettings set --name <app> --resource-group <group> \
--settings FUNCTIONS_REQUEST_BODY_SIZE_LIMIT=1048576 @settings.json
az functionapp deployment source config-zip --name <app> --resource-group <group> --src dist/azure.zip- Settings are the app's application settings, which the function sees as environment variables, as on
Node:
settings.jsonis{"TOKEN_GITHUB": "nr_...", ...}, with every value a string. - The function takes every path and method under the app's root, without the usual
/apiprefix, and asanonymous, leaving authentication to the relay's tokens. - The host reads a body that comes with a length into memory before the function runs, up to 100 MB by
default.
FUNCTIONS_REQUEST_BODY_SIZE_LIMITbounds that: up to 1 MB, a body over 64 KB still gets the relay's 413; past it, the host answers an empty 500. A chunked body streams to the function, which turns on HTTP streams because the host would otherwise hand it over empty. - A private network, and port 25 on the subscription types Azure allows it, are reachable only through virtual network integration.
TypeScript, with no runtime dependencies, except that the Azure bundle holds @azure/functions, through
which the host calls a function. The core (src/app.ts, src/core, src/inbound, src/outbound) uses
only web-standard APIs such as fetch, crypto and streams. Each platform's entry under
src/platforms adapts its runtime to the core and passes in a TCP connect: cloudflare:sockets on
Workers, node:net and node:tls on Node, Lambda and Azure. The toolchain (wrangler, esbuild, vitest
with Cloudflare's plugin, typescript) is development-only. Node 24.
npm ci
npm test # vitest: the core on both Workers (Miniflare) and Node, and each platform's own
npm run typecheck # wrangler types, then tsc over src and test, with Workers' types and with Node's
npm run build # bundles for Workers (wrangler deploy --dry-run, no credentials), Node, Lambda, Azure
npm run dev # a local Workerworker-configuration.d.ts is generated by wrangler types and not committed, since it changes with
every wrangler release. Outbound adapters get fetch and connect passed in, so tests assert on what a
fake fetch or a scripted fake server receives instead of mocking globals. The Node tests need openssl
on the path, to make a throwaway certificate. test/docker/smoke.sh <image> runs a built image as a
deployment would and checks it serves, runs as a user other than root, passes its HEALTHCHECK and stops
cleanly on docker stop. test/lambda/smoke.sh [dist/lambda] runs the Lambda bundle in AWS's Node 24
base image, whose Runtime Interface Emulator invokes it as Lambda would. test/azure/smoke.sh [dist/azure] runs the Azure package in Microsoft's Functions host image for Node 24, with bodies sent
with a length and chunked, and one past the host's limit.
npm run test:live sends through real channels instead: a Loki push that it then reads back, mail to one
SMTP server, plain text over 587 and HTML over 465, and HTML mail through the Gmail API. Mail passes once
it shows up in the recipients' mailbox, among the newest mail read over IMAP on 993, so LIVE_SMTP_TO
and LIVE_GMAIL_TO must both deliver there. That mailbox may be the sender's own: folders that may hold
the sender's copy (sent, drafts, trash, and virtual ones such as all mail) are skipped. Landing in junk
still passes, with a warning. Each body links the commit and the run. It needs LIVE_LOKI_URL,
LIVE_LOKI_USER and LIVE_LOKI_TOKEN (logs:write and logs:read); LIVE_SMTP_HOST,
LIVE_SMTP_USER, LIVE_SMTP_PASSWORD, LIVE_SMTP_FROM and LIVE_SMTP_TO; LIVE_GMAIL_CLIENT_ID,
LIVE_GMAIL_CLIENT_SECRET, LIVE_GMAIL_REFRESH_TOKEN, LIVE_GMAIL_FROM and LIVE_GMAIL_TO; and
LIVE_IMAP_HOST, LIVE_IMAP_USER and LIVE_IMAP_PASSWORD. With LIVE_IMAGE naming an image, the relay
under test is a container of it, given the same settings, instead of the Worker. The
Live workflow runs it both ways on main, weekly and by hand.
- Inbound: implement
Inbound(parse(body) → Draft[], throwingBadRequeston invalid input) and map a path to it insrc/app.ts. - Outbound: implement
Outbound(type,params,send(message, params, ctx)) and register it insrc/outbound/index.ts.sendgets the channel's own parameters, never the environment;paramsis what a channel of the type must configure and what/healthzchecks;optionallists the ones it may configure, passed when set and never checked. A type that takes recipients implementsrecipients(value, params), returning why it refuses an entry ofto, and reads its own entry asmessage.to?.[ctx.channel].TYPEis reserved. - Platform: add an entry under
src/platformsthat turns the platform's request into aRequestand callshandle(request, env, deps), with the platform's settings asenv, andfetch,connect,nowandsleepindeps.connectprovidesConnectionfromsrc/core/deliver.ts, the part of Cloudflare's sockets API thatsmtpuses.
The message format only grows: new optional fields are fine, changing or removing fields is not. A format that needs an incompatible change gets a new endpoint instead.
See SECURITY.md.