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.

See how a send flowsThe contract
NotificationService.SendPushtoken aud: finance · sample
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.

  1. finance sends SendPush with an idempotency key. Ralvasend answers queued and the request is over.
  2. The worker’s next poll claims the job and hands it to the push service.
  3. 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.

  1. Your app

    Calls SendPush with a service-account token whose audience is the request’s application. A user’s token is refused, even with the right audience.

    aud = finance
  2. The 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 queued
  3. The worker

    Polls every 5 s when idle and claims due jobs with FOR UPDATE SKIP LOCKED and a 2-minute lease, so several replicas are safe. Email and push run in separate pools.

    lease 2 m · 4 at a time
  4. The 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_file
  5. Settled

    The delivery ends sent or failed with one short reason. Your app reads it back with GetDelivery or ListDeliveries, 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.

  1. Queued

    Accepted. Waiting, or being retried: a retry leaves it queued.

  2. Sent

    The channel accepted it. The mail relay or the push service took the message.

  3. 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.

What goes wrong
retries_exhausted
  1. #1+0 stransient failure, next in 30 s
  2. #2+30 stransient failure, next in 1 m
  3. #3+1 m 30 stransient failure, next in 2 m
  4. #4+3 m 30 stransient failure, next in 4 m
  5. #5+7 m 30 stransient failure, next in 8 m
  6. #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.0
    application: "finance"idempotency_key: "rent-2026-11-05-u42"title: "Rent is due tomorrow"
  • request 2 · retry after a timeout08:00:04.0
    application: "finance"idempotency_key: "rent-2026-11-05-u42"title: "Rent is due tomorrow"

The delivery · d_01JC8Q…

Sent

Both calls returned this same delivery.

2requests
1delivery
1push

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.

financetoken aud: finance
ralvagymtoken aud: ralvagym

ralvasend

finance sendermail from · VAPID key file
ralvagym sendermail from · VAPID key file

one worker · email pool · push pool

finance and ralvagym as example callers. Senders are read from the apps file; keys are mounted files.

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"
      }
    }
  }
}
Try a call
sent

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.

Overview: Attention list, totals and why deliveries failed
Overview · Attention list, totals and why deliveries failedsample
Deliveries: Newest first, across applications
Deliveries · Newest first, across applicationssample
Queue: What is waiting, retrying and dead-lettered
Queue · What is waiting, retrying and dead-letteredsample
Deliveries at 390 px: Filters become a sheet and each row stacks
Deliveries at 390 px · Filters become a sheet and each row stackssample

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.

NotificationService · read from notifier.proto in contracts v0.29.0
CallWhat it doesRequest
SendEmailDelivers one email.SendEmailRequest
SendPushDelivers one web push message.SendPushRequest
GetDeliveryReturns one delivery by id.GetDeliveryRequest
ListDeliveriesReturns a page of deliveries.ListDeliveriesRequest
  1. Get a service-account token. The identity service mints it with your application as the audience. A user’s token is refused.
  2. Add your application to the apps file. Its sender address, and a VAPID key file for push. See Applications.
  3. Send with an idempotency key. Pass the recipient yourself; the answer is queued. Read it back with GetDelivery. Go apps can use the notifierclient library.
SendPushRequest
FieldRequiredMax lengthMeaning
applicationyes100Application the send belongs to.
idempotency_keyyes255Required idempotency key; a repeat key returns the first delivery.
recipientyes2048Recipient's push subscription endpoint.
titleno200Notification title.
bodyno—Notification body.
p256dhyes256The subscription's p256dh public key (base64url), required to encrypt the payload to the recipient.
authyes256The subscription's auth secret (base64url), required to encrypt the payload to the recipient.
urlno2048Optional 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.