Ralvasend · email and Web Push
Your app decides when. Ralvasend makes it arrive.
One service delivers email and Web Push for every app you run. A send is answered queued at once and a worker delivers it, so a down channel never blocks a request.
SendPush { application: "finance", idempotency_key: "rent-2026-11-05-u42", recipient: "https://push.example/…", p256dh: "BNc1…", auth: "tB4…", title: "Rent is due tomorrow", body: "$850 · tap to open", url: "/obligations" } ← delivery d_01JC8Q… status queued
Delivery status: sent.
- finance sends
SendPushwith an idempotency key. Ralvasend answers queued and the request is over. - The worker’s next poll claims the job and hands it to the push service.
- The push service accepts it. The delivery is sent and the phone lights up.
Played at real speed from the request. Times, ids and text are samples; an idle worker polls every 5 s, so the wait for a claim is 0 to 5 s.
01 · How a send flows
Five stops between your app and a buzz.
The request ends at the second stop. Everything after it is the worker’s job, so a slow channel never holds your app.
Your app
Calls
SendPushwith a service-account token whose audience is the request’sapplication. A user’s token is refused, even with the right audience.aud = financeThe API
Writes a queued delivery and its outbox job in one transaction and answers at once. A repeat idempotency key returns the first delivery.
status queuedThe worker
Polls every 5 s when idle and claims due jobs with
FOR UPDATE SKIP LOCKEDand a 2-minute lease, so several replicas are safe. Email and push run in separate pools.lease 2 m · 4 at a timeThe channel
Sends with the application’s own sender: its mail address, or its VAPID key, read from a mounted file. Those credentials never leave the service.
vapid_private_key_fileSettled
The delivery ends sent or failed with one short reason. Your app reads it back with
GetDeliveryorListDeliveries, scoped to its application.sent · or failed + reason
02 · Statuses
Three stored statuses. That is the whole vocabulary.
Attempting and waiting are the worker’s business. Your app only ever reads one of three words.
- Queued
Accepted. Waiting, or being retried: a retry leaves it queued.
- Sent
The channel accepted it. The mail relay or the push service took the message.
- Failed
One short reason, never a provider payload. Five reasons in all, listed below.
03 · Retries
Keeps trying. Then says why it stopped.
Pick what goes wrong. A transient failure retries after 30 s, then doubles. A permanent one fails at once, with a reason your app can act on.
retries_exhausted- #1+0 stransient failure, next in 30 s
- #2+30 stransient failure, next in 1 m
- #3+1 m 30 stransient failure, next in 2 m
- #4+3 m 30 stransient failure, next in 4 m
- #5+7 m 30 stransient failure, next in 8 m
- #6+15 m 30 sfailed · retries_exhausted
Six attempts over about 15 minutes, all transient: the delivery fails with retries_exhausted.
Defaults from worker.go: 6 attempts, 30 s after the first failure, doubling, capped at 1 h (the cap matters only if you raise the attempt count). Real delays carry jitter. The time axis is square-root scaled. delivery_error covers any other failure.
04 · Idempotency
Send it twice. It arrives once.
Every send carries an idempotency key. A repeat key returns the first delivery and queues nothing, so your app can retry a timed-out call without a double buzz.
- request 108:00:00.0application: "finance"idempotency_key: "rent-2026-11-05-u42"title: "Rent is due tomorrow"
- request 2 · retry after a timeout08:00:04.0application: "finance"idempotency_key: "rent-2026-11-05-u42"title: "Rent is due tomorrow"
The delivery · d_01JC8Q…
SentBoth calls returned this same delivery.
Keys are per application. A key is remembered as long as its delivery is kept: 90 days by default.
05 · Applications
Two apps. Two senders. One worker.
Each app authenticates as itself. Its sender address and VAPID key stay inside the service, and it only ever sees its own deliveries.
ralvasend
one worker · email pool · push pool
NOTIFIER_APPS_FILE · config/apps.json.example
{ "applications": { "app-code": { "email": { "from": "Example <no-reply@app.example.com>" }, "push": { "subscriber": "mailto:ops@app.example.com", "vapid_public_key": "B<base64 uncompressed P-256 public key from the webpush vapidgen>", "vapid_private_key_file": "/run/vapid/app-code.key" } } } }
The delivery and its status. Reads are scoped to the caller’s application.
06 · The console
Delivery, with an operator's view.
The same worker, seen from the other side: sign in through the identity service, watch the queue, open a delivery and read why it stopped. The captures are from the backend-less demo, so ids, times and reasons are samples.




The console is part of the service
It is served by the notifier at /admin/, embedded in the same binary, and reads the deliveries the worker wrote. Operators are admitted by the identity service; the console never holds a second user database.
The status page is read-only, the queue can retry or discard a dead letter, and every action names the application it belongs to.
07 · The trade-off
It delivers. It never decides.
Ralvasend owns
- Delivery and retries
- The reason a delivery stopped
- Each app’s sender and VAPID key
- Idempotency per application
Your app keeps
- Whether to notify, and when
- Reminder rules and preferences
- The record of what it already sent
- The recipient: an address or a push subscription
What you accept
Your app still writes every rule and passes the recipient on each send. Ralvasend does not look users up.
In exchange, one service owns delivery for every app, and no app carries mail or VAPID code.
08 · Questions
Short answers.
Does it decide when to send reminders?
No. Policy stays in the app that owns it: reminder rules, preferences and the record of what was sent. The service receives a send and delivers it.
How fast does a send go out?
The request answers queued at once. An idle worker polls every 5 s; with NOTIFIER_DISPATCH=nats, due jobs are relayed every second to several replicas.
Do I need NATS?
No. Postgres is the authority and the default dispatch is a poll. NATS only distributes work and outcome events, and switching either way changes no stored data.
Can one app read another app’s deliveries?
No. A token is valid for one application, and another application’s delivery answers not_found, not forbidden, so it cannot even learn the delivery exists.
How long is an idempotency key remembered?
As long as its delivery is kept: 90 days by default (NOTIFIER_RETENTION_DAYS). After that, the same key sends again.
What happens to a dead push subscription?
The delivery fails with recipient_gone and is not retried. The app’s Pruner, from the notifierclient library, lists those deliveries and deletes the endpoint from its own store.
09 · The contract
Four calls. Connect, gRPC or gRPC-Web.
One proto, ralvarezdev.notifier.v1, served over Connect, gRPC and gRPC-Web from the same handler. There is no REST surface. Requests are validated at the boundary, so a bad one is INVALID_ARGUMENT on every transport.
| Call | What it does | Request |
|---|---|---|
| SendEmail | Delivers one email. | SendEmailRequest |
| SendPush | Delivers one web push message. | SendPushRequest |
| GetDelivery | Returns one delivery by id. | GetDeliveryRequest |
| ListDeliveries | Returns a page of deliveries. | ListDeliveriesRequest |
- Get a service-account token. The identity service mints it with your application as the audience. A user’s token is refused.
- Add your application to the apps file. Its sender address, and a VAPID key file for push. See Applications.
- Send with an idempotency key. Pass the recipient yourself; the answer is
queued. Read it back withGetDelivery. Go apps can use thenotifierclientlibrary.
| Field | Required | Max length | Meaning |
|---|---|---|---|
| application | yes | 100 | Application the send belongs to. |
| idempotency_key | yes | 255 | Required idempotency key; a repeat key returns the first delivery. |
| recipient | yes | 2048 | Recipient's push subscription endpoint. |
| title | no | 200 | Notification title. |
| body | no | — | Notification body. |
| p256dh | yes | 256 | The subscription's p256dh public key (base64url), required to encrypt the payload to the recipient. |
| auth | yes | 256 | The subscription's auth secret (base64url), required to encrypt the payload to the recipient. |
| url | no | 2048 | Optional URL the notification opens when clicked. |
SendEmail carries application, idempotency_key, recipient, subject and a plain-text body. The service never looks the recipient up: the caller supplies it.