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).
The exact wire format for outbound client webhooks (Octopus → client; the client registers an HTTPS URL, we POST signed events). For registration, retry semantics, and the concurrency model see Notifications & Email; this page is the payload reference. Inbound vendor→Octopus webhooks are a different, unrelated surface.
The envelope
Every event is this 4-key object (models.WebhookPayload, in client_payout_webhook.go:102):
{
"id": "evt_A1b2C3d4E5f6G7h8I9j0Kk",
"type": "order.delivered",
"created_at": "2026-07-24T10:15:30Z",
"data": { }
}| Field | Type | Notes |
|---|---|---|
id | string | evt_ + 22 base62 chars (from 16 random bytes). Also the X-Event-ID header; stable across retries and shared across all URLs a single event fans out to. |
type | string | One of the 15 event types. |
created_at | string | RFC3339, second precision (time.Now().Format(RFC3339)), set at build time. |
data | object | Event-specific (below). |
Delivery headers (services/webhook_sender.go:41)
POST, Content-Type: application/json, User-Agent: octopusrewards.com/webhook-agent, 30s timeout, success = HTTP 2xx.
| Header | Value |
|---|---|
X-Webhook-ID | the target web_hook_urls.id (decimal string) |
X-Event-ID | the envelope id |
X-Event-Type | the event type |
X-Timestamp | Unix epoch seconds (send time) — note this differs from body created_at (RFC3339, build time) |
X-Signature | lowercase hex HMAC-SHA256 of the raw body (see Signing) |
Event catalog
ClientWebhookEventTypes (webhook.go:119) — the only subscribable/deliverable set, 15 events. Subscription is web_hook_urls.events (JSON array); an empty array = all events.
| Event | data keys | Conditional | amount type |
|---|---|---|---|
order.delivered / .partially_delivered / .cancelled / .failed | id, client_reference, status, sub_status, product_name, denomination, quantity, amount, delivered_quantity, delivered_on, created_at | client_reference / delivered_quantity / delivered_on nullable | number (2dp) |
wallet.credited / .debited | wallet_id, ledger_id, amount, currency, reference_number, remarks | — | string |
topup.delivered / .cancelled | id, client_reference, status, product_name, variant_name, amount, currency, completed_at | client_reference / product_name / variant_name / completed_at nullable | number |
topup.failed | + failure_code, failure_reason, is_user_fixable | failure_* only here | number |
esim.delivered / .cancelled / .installed / .activated / .depleted | id, client_reference, status, activation_status, product_name, country_code, data_amount_gb, validity_days, amount, currency, iccid | most nullable; activation_code always omitted | number |
esim.failed | + failure_code, failure_reason, is_user_fixable | failure_* only here | number |
payout.* | — | not delivered — TriggerPayoutEvent has no callers | — |
Two amount types on the wire
wallet.* serializes amount as a JSON string ("500.00") because the Go field is a string. Every other event's amount/denomination is a JSON number at 2dp (50.00) — decimal marshals unquoted via StringFixed(2). Integrators must handle both.
Order events
TriggerOrderEvent (webhook_service.go:69). One shape for all four; only type/status/sub_status differ. No failure fields (unlike topup/eSIM).
| key | type | null when |
|---|---|---|
id | number (uint64) | — |
client_reference | string | null | client gave none |
status | string | — |
sub_status | string | — (may be "") |
product_name | string | — |
denomination | number | — |
quantity | number | — |
amount | number | — |
delivered_quantity | number | null | not yet valid |
delivered_on | string (RFC3339) | null | not yet valid |
created_at | string | — |
{
"id": "evt_9zZyXw...", "type": "order.delivered", "created_at": "2026-07-24T10:15:30Z",
"data": {
"id": 84213, "client_reference": "PO-2026-5567",
"status": "DELIVERED", "sub_status": "COMPLETED",
"product_name": "Amazon Gift Card", "denomination": 25.00,
"quantity": 2, "amount": 50.00,
"delivered_quantity": 2, "delivered_on": "2026-07-24T10:15:29Z",
"created_at": "2026-07-24T10:14:00Z"
}
}Wallet events
TriggerWalletEvent (webhook_service.go:115). Same for credited/debited. All fields always present.
| key | type |
|---|---|
wallet_id | number |
ledger_id | number |
amount | string |
currency | string |
reference_number | string |
remarks | string (may be "") |
{
"id": "evt_Kk0j9I8h...", "type": "wallet.credited", "created_at": "2026-07-24T10:20:00Z",
"data": {
"wallet_id": 3391, "ledger_id": 220145, "amount": "500.00",
"currency": "USD", "reference_number": "TXN-88fa21",
"remarks": "Refund for order PO-2026-5567"
}
}Top-up events
TriggerTopupEvent (webhook_service.go:139). "topup" is the public name; the internal model is Recharge. failure_* keys appear only on topup.failed.
| key | type | notes |
|---|---|---|
id | number | — |
client_reference | string | null | — |
status | string | — |
product_name | string | null | null if variant→product lookup fails |
variant_name | string | null | — |
amount | number | — |
currency | string | — |
completed_at | string | null | from UpdatedAt |
failure_code | string | null | topup.failed only (INVALID_RECIPIENT, PRODUCT_OUT_OF_STOCK, UNKNOWN, …) |
failure_reason | string | null | topup.failed only |
is_user_fixable | bool | topup.failed only |
{
"id": "evt_Mm1n2O3p...", "type": "topup.failed", "created_at": "2026-07-24T10:26:10Z",
"data": {
"id": 55121, "client_reference": null, "status": "FAILED",
"product_name": null, "variant_name": null,
"amount": 9.99, "currency": "USD", "completed_at": "2026-07-24T10:26:10Z",
"failure_code": "INVALID_RECIPIENT",
"failure_reason": "The player ID could not be found.",
"is_user_fixable": true
}
}eSIM events
TriggerEsimEvent (webhook_service.go:194). All six (delivered/cancelled/failed + lifecycle installed/activated/depleted) share one shape; failure_* only on esim.failed.
| key | type | notes |
|---|---|---|
id | number | — |
client_reference | string | null | — |
status | string | PENDING/DELIVERED/FAILED/CANCELLED |
activation_status | string | NOT_INSTALLED/ACTIVE/EXPIRED |
product_name / country_code | string | null | — |
data_amount_gb | number | null | — |
validity_days | number | null | — |
amount | number | — |
currency | string | — |
iccid | string | null | plaintext (stored encrypted); null pre-provision |
failure_code / failure_reason / is_user_fixable | — | esim.failed only |
activation_code is deliberately never sent
The single-use LPA/QR credential (activation_code) is omitted from every eSIM webhook by design (webhook_service.go:190) — clients fetch it from GET /esim/orders/:id. Only the iccid (SIM serial) is sent. This is the eSIM airtight rule. Also note: there is no INSTALLED activation_status value — esim.installed fires while activation_status is still NOT_INSTALLED; esim.activated is the one that flips it to ACTIVE.
{
"id": "evt_Qq4r5S6t...", "type": "esim.activated", "created_at": "2026-07-24T10:30:00Z",
"data": {
"id": 771, "client_reference": "esim-order-42",
"status": "DELIVERED", "activation_status": "ACTIVE",
"product_name": "Europe 10GB 30 Days", "country_code": "EU",
"data_amount_gb": 10, "validity_days": 30,
"amount": 18.50, "currency": "USD",
"iccid": "8944000000000000123"
}
}Signing & verification
SignWebhookPayload (webhook_sender.go:106): HMAC-SHA256(key = webhook secret, msg = raw body bytes), lowercase hex, in X-Signature.
- Key =
web_hook_urls.token=whsec_+ 64 hex chars (32 random bytes). The full string including thewhsec_prefix is the HMAC key. Never transmitted. - Message = the exact raw response body bytes — no timestamp/headers mixed in.
secret = "whsec_..." // from panel, exact string
rawBody = <bytes of the HTTP request body> // do NOT re-serialize
expected = hex(HMAC_SHA256(key=secret, msg=rawBody))
received = header["X-Signature"]
if !constant_time_equals(expected, received): reject(401)
// optional: reject if abs(now - X-Timestamp) > tolerance (replay guard)
// idempotency: dedupe on X-Event-ID / body.idVerify over the raw bytes before JSON parsing — re-serializing changes byte order/spacing and breaks the HMAC.
Rotate (POST /client/api/webhooks/:id/rotate-secret): generates a new whsec_…, persists it, returns it once. The old key is invalid immediately — the signer reads Token fresh from the DB per delivery, so there's no dual-key overlap window. Creation also generates the secret server-side (never client-supplied) and requires an https:// URL.
Delivery semantics (recap)
At-least-once; one webhook_deliveries row per subscribed URL; MaxAttempts = 5; retry backoff 1/2/4/8/16 min; claim via FOR UPDATE SKIP LOCKED; stuck-processing reaped after 3 min. Idempotency = the stable id/X-Event-ID. Full detail in Notifications.
No-vendor-leak guarantee
Confirmed — no outbound payload carries vendor identity. Order/topup/eSIM builders emit only product/variant names; wallet/payout carry no vendor fields; the eSIM builder omits activation_code. The models.OrderItemWebhook struct declares VendorID/VendorName but is dead — the order payload is a hand-built map[string]any that never touches it. Recommend deleting the dead struct so it can't be accidentally serialized later. See no-vendor-in-customer-facing.
Inbound webhooks are a different surface
Vendor→Octopus callbacks (we receive) are unrelated: direct_topup/types.WebhookPayload, esim/types.EsimWebhookPayload, types.NormalizedWebhookPayload, verified via webhooks/svix_verifier.go / per-vendor secrets. Different shapes, different headers, inbound-only. See Notifications → inbound vs outbound and the per-vendor webhook rows.
Key files
- Envelope:
database/models/client_payout_webhook.go:102; eventsdatabase/models/webhook.go:119 - Builders:
services/webhook_service.go(TriggerOrderEvent/TriggerWalletEvent/TriggerTopupEvent/TriggerEsimEvent) - Signing/headers:
services/webhook_sender.go; event idutils/utils.go:275; secretutils/code_generator.go - Registration/rotate:
http/handler/client_webhooks.go
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.
Shopify Integration
The inbound Shopify sales channel — per-tenant order polling, SKU→product mapping, voucher provisioning, fulfilment and cancellation on the 2026-04 GraphQL API. Includes the critical client_id=0 ingest bug.