Notifications & Email
How Octopus tells customers about their orders — the per-family notifiers, the terminal-only rule, the Jet email templates and design system, outbound HMAC-signed client webhooks with retry/backoff, and the event→notification matrix.
Every customer-facing notification fans out to two channels: transactional email and outbound client webhooks. There are three per-family notifiers (voucher / topup / eSIM), one shared outbound-webhook engine, and a strict terminal-only firing rule.
The single biggest gotcha
There are two generations of the voucher notification path. The newer unified OrderNotifier (services/order_notifier.go) is fully implemented but has zero production callers — the live voucher flow is the older inline code in create_voucher_order.go + order.go. Consequence: voucher customers get an email only on DELIVERED — no partial or cancelled emails are sent, even though the templates and builder support exist. See Dead & divergent code.
Architecture
| Notifier | File | Channels | Wired in production? |
|---|---|---|---|
OrderNotifier (voucher) | services/order_notifier.go | email + webhook | No — zero callers |
| Inline voucher path | create_voucher_order.go, order.go | email (delivered only) + webhook (all 4) | Yes |
EsimNotifier | services/esim_notifier.go | email + webhook + lifecycle-webhook | Yes |
RechargeNotifier | services/recharge_notifier.go | email + webhook | Yes |
All notifier dispatch runs in separate fire-and-forget goroutines with a detached context.Background() so request cancellation can't abort a send.
The terminal-only rule (verified)
Notifications fire only at terminal (status[, sub_status]) pairs. Each notifier maps status→kind and no-ops otherwise; call sites re-load the row post-commit and gate on IsFinal() before notifying.
| Family | Terminal pairs that notify | Classifier |
|---|---|---|
| Voucher | (Delivered, Completed), (PartiallyDelivered, Partial), (Cancelled, Refunded) | classifyOrderTransition (order_notifier.go:119) |
| eSIM | Delivered / Failed / Cancelled | EsimKindForStatus (esim_email.go:29) |
| Recharge | Delivered / Failed / Cancelled | RechargeKindForStatus (recharge_email.go:34) |
In-flight states (VENDOR_ORDER_CREATED, provisioning, recharge PENDING/RECHARGED) never notify. The only non-terminal events emitted at all are the eSIM post-delivery lifecycle webhooks (installed / activated / depleted) — genuine one-shot state transitions, webhook-only.
This matches the no-webhooks-in-flight-substatus and notification-scope-discipline rules. Note the stale comment in classifyOrderTransition claiming "FAILED is no longer a status" — OrderFailed / order.failed are still live in the inline path.
Email generation & sending
Transport — selected by EMAIL_PROVIDER env (default sendgrid):
| Provider | File | Env | Timeout |
|---|---|---|---|
| SendGrid (v3 API) | email/sendgrid.go | SENDGRID_API_KEY, EMAIL_DEFAULT_SENDER | 10s |
| SMTP (TLS 465 / STARTTLS 587) | email/smtp.go | SMTP_HOST/PORT/USERNAME/PASSWORD/SECURE_MODE/INSECURE_CERT | 15s |
Interface email.MessageDeliveryService.DeliverMessage(ctx, MessageContent); global singleton via InitialiseEmail / GetEmailProvider.
SMTP transport silently drops attachments. constructRawMessage (smtp.go:210) builds a single-part body and ignores FileReferences — only SendGrid handles attachments. Under EMAIL_PROVIDER=smtp, bulk-voucher XLSX files are not delivered. Prod uses SendGrid; keep it that way or fix SMTP multipart.
Templates — Jet (github.com/gofiber/template/jet/v2). Dev reads views/email from disk; prod uses an embedded FS. RenderEmailTemplates(base, data) always renders both base.txt and base.html, both attached to the message. Data is map[string]any; Jet requires capitalised field access.
Voucher email builder & the 6 permutations
BuildVoucherEmailData (services/voucher_email.go:61) reloads order+items+product, decrypts voucher fields (decryptOrPassthrough, degrades to empty on failure so one bad row can't fail the email), and returns a bundle with a Cleanup() for any temp attachment.
| Case | Attachment | Payload |
|---|---|---|
Single (qty==1) delivered | none | inline, sets hasCode/hasPIN/hasClaimURL |
Bulk (>1) delivered | XLSX of all items | nil |
| Partial | XLSX of delivered-only items + refund math | nil |
| Cancelled | none | refund amount |
The 6 permutations are the single-voucher delivered layouts, driven by which of {Code, Pin, ClaimURL} are present (rendered in views/email/voucher/delivered.html.jet via hasCode/hasPIN/hasClaimURL): code-only; code+PIN; claim+PIN; claim-only; PIN-only; code+PIN+claim. Design mock: views/email/preview/voucher_permutations.html.
Excel (services/excel_generator.go, excelize v2): columns Product Name / Denomination / Code / Pin / Voucher Link / Reference / Expires At. Written to /tmp/octopus-orders/…xlsx, removed by CleanupExcelFile via the caller's defer content.Cleanup().
eSIM & recharge builders
- eSIM (
esim_email.go:68): delivered template getsactivationCode,iccid, and a QR image URL (https://api.qrserver.com/...). Failed/cancelled includerefundAmount. - Recharge (
recharge_email.go:61): masks the destination — phone as+CC AA ***NNNN, IDs keep prefix+last4.RechargeClaimURL = https://claim.octopuscards.iofor user-fixable retries. - ⚠️ No
voucher/failedtemplate exists (the voucher builder rejects any kind but Delivered/Partial/Cancelled), while recharge and eSIM both have failed templates. Asymmetric.
Email design system
Shared layout per family: views/email/{voucher,esim,recharge}/_layout.{html,txt}.jet (each family has its own copy of the same token set).
- Palette / elevation (light inline, dark via
prefers-color-scheme+ Outlook[data-ogsc]): texttext-ink/text-slate/text-mist; surfacesbody-bg→card-bg→surface-bg/surface-bg-strong(3-tier elevation); accent brand blue#3BA8D9. - State→color via
notice-*classes:notice-orange(warning),notice-red(failure),notice-amber(caution),notice-slate(neutral), each with a-strongforeground. - Fonts: DM Serif Display (headings), Nunito (body), DM Mono (codes), with MSO/Outlook fallbacks. Accordions via
<details>.
No-vendor-info rule — and two leak vectors
Enforced by construction: email builders resolve only product/variant/country names, never vendor. No vendor string appears in any shipped .jet. Two latent risks, neither of which reaches customers today:
- The static mock
views/email/preview/voucher_partial.html:251contains customer-facing copy "Vendor inventory ran short of…" — reword it so it can't be copied into a live template. models.OrderItemWebhookhas deadVendorID/VendorNamefields — a leak vector if that struct is ever marshalled into an outbound payload.
Outbound client webhooks
Registration (client portal)
http/handler/client_webhooks.go, CRUD over web_hook_urls:
POST /client/api/webhooks— requires HTTPS; the signing key is generated server-side (GenerateWebhookSecret), never accepted from the client, returned once. Optionaleventsvalidated againstIsValidClientWebhookEvent.POST /:id/rotate-secret— new key, old invalid immediately.POST /:id/test— synthetictype:"test"payload, not persisted.WebHookURL.SubscribesToEventtreats an emptyeventslist as "all events" (legacy rows subscribe to everything).
Payload & signing
Envelope models.WebhookPayload: { id: "evt_...", type, created_at, data{} } (webhook_service.go:91). SendWebhook (webhook_sender.go:41) POSTs JSON with headers X-Webhook-ID, X-Event-ID, X-Event-Type, X-Timestamp, and X-Signature = HMAC-SHA256(payload, token) hex. The signing key is never transmitted; success = HTTP 2xx.
The eSIM activation code is deliberately omitted from webhook payloads — it goes only to email. Webhooks carry the ICCID instead. Failure fields (failure_code/failure_reason/is_user_fixable) appear only on the *.failed events.
Queue → delivery → retry
- Cron:
WebhookTask(*/1 * * * *, batch 50) →ProcessPendingDeliveries. Bounded fan-out of 10 viaerrgroup; one bad delivery never cancels the batch. - Claim:
FOR UPDATE SKIP LOCKEDflipspending→processingand incrementsattempt_countin one UPDATE — prevents duplicate sends across overlapping ticks / instances. - Retry/backoff: exponential
1<<(attempt-1)minutes = 1, 2, 4, 8, 16; atattempt >= MaxAttempts→markFailed(terminalfailed, no separate DLQ). - Crash recovery:
ReapStuckProcessingDeliveriesre-queuesprocessingrows older than 3 min without resettingattempt_count(a poison endpoint can't loop forever). - Idempotency: stable
event_id(evt_...) in body +X-Event-ID, constant across retries and across all URLs of one event. Gap: no dedup on enqueue — a double-fired trigger queues two deliveries with differentevent_ids; dedup relies on single-fire callers + the client honoringevent_id.
Payout client webhooks are a separate task/service (scheduler/payout_webhook_task.go) — and are currently never enqueued (see Payouts).
Inbound vs outbound
Two entirely separate systems:
- Inbound (vendor → Octopus):
services/vendor_webhook_service.goreceives async fulfilment callbacks, updates internal state, and on terminal transition triggers the outbound notifiers. This is where vendor topology lives — logged tovendor_webhook_logs, firewalled from customer output. - Outbound (Octopus → client): everything on this page. Never carries vendor identity.
The notifier layer is the boundary: inbound processors call notifiers; notifiers emit vendor-free payloads/emails.
Event → notification matrix
| Event | Webhook type | Email? | Trigger |
|---|---|---|---|
| Voucher delivered | order.delivered | Yes | create_voucher_order.go:1015 + order.go:525 |
| Voucher partially delivered | order.partially_delivered | No (live path) | create_voucher_order.go:1033 |
| Voucher failed | order.failed | No | create_voucher_order.go:1049 |
| Voucher cancelled | order.cancelled | No (live path) | create_voucher_order.go:1065 |
| Topup delivered / failed / cancelled | topup.* | Yes | topup.go:2063, admin_recharge_actions.go:154 |
| eSIM delivered / failed / cancelled | esim.* | Yes (QR + activation code on delivered) | vendor_webhook_service.go:544, admin_esim_actions.go:147 |
| eSIM installed / activated / depleted | esim.* | No (webhook-only) | esim_order.go:1147/1150, jobs/esim_expiry_job.go:113 |
| Wallet credited / debited | wallet.* | No | WebhookService.TriggerWalletEvent |
Constants: database/models/webhook.go:85; subscribable set ClientWebhookEventTypes (:119).
Delivery guarantees
- Webhooks: at-least-once, up to 5 attempts with backoff; terminal
failedis the dead-letter state (no DLQ table); crash recovery via reaper; client-side idempotency via stableevent_id. - Emails: best-effort fire-and-forget — no retry, no persistence. A failed send is logged and dropped; manual resend (
ResendOrderEmail→sendOrderDeliveryEmail) is the only recovery.
Dead & divergent code
OrderNotifierfully implemented but unwired — live voucher path is the inlinecreate_voucher_order.go/order.gocode. Two generations coexist; pick one.- Voucher partial/cancelled emails never sent in production despite templates + builder support.
- No
voucher/failedtemplate while recharge/eSIM have one. - SMTP drops attachments (
smtp.go:210). - Stale comment "FAILED is no longer a status" (
order_notifier.go:115) — it still is. - Vendor-leak vectors: preview mock copy + dead
OrderItemWebhook.VendorID/VendorName. - No enqueue-side webhook dedup.
factory.goNewEmailServiceis a parallel unused provider constructor (live path isemail.go:createProvider).
Key files
- Notifiers:
services/{order_notifier,esim_notifier,recharge_notifier,esim_notification}.go - Email builders:
services/{voucher_email,esim_email,recharge_email,excel_generator}.go - Transport:
email/{email,sendgrid,smtp,templates}.go; templatesviews/email/ - Outbound webhooks:
services/{webhook_service,webhook_sender}.go;http/handler/client_webhooks.go;scheduler/webhook_task.go;database/repo/webhook_delivery.go - Inbound:
services/vendor_webhook_service.go - Models:
database/models/{webhook,web_hook_url}.go
Payouts
The payout product line — beneficiaries, providers, scheduled payouts, the money-out ledger, and the two fulfilment providers (Merit, Ledig). Includes the critical "pipeline wired but not driven" finding.
Webhook Payloads
The exact JSON for every outbound client webhook — the envelope, all 15 event types with field tables and example bodies, the HMAC-SHA256 signing + verification, and the quirks (wallet amount is a string, activation_code always omitted).