OctoWiki

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": { }
}
FieldTypeNotes
idstringevt_ + 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.
typestringOne of the 15 event types.
created_atstringRFC3339, second precision (time.Now().Format(RFC3339)), set at build time.
dataobjectEvent-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.

HeaderValue
X-Webhook-IDthe target web_hook_urls.id (decimal string)
X-Event-IDthe envelope id
X-Event-Typethe event type
X-TimestampUnix epoch seconds (send time) — note this differs from body created_at (RFC3339, build time)
X-Signaturelowercase 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.

Eventdata keysConditionalamount type
order.delivered / .partially_delivered / .cancelled / .failedid, client_reference, status, sub_status, product_name, denomination, quantity, amount, delivered_quantity, delivered_on, created_atclient_reference / delivered_quantity / delivered_on nullablenumber (2dp)
wallet.credited / .debitedwallet_id, ledger_id, amount, currency, reference_number, remarksstring
topup.delivered / .cancelledid, client_reference, status, product_name, variant_name, amount, currency, completed_atclient_reference / product_name / variant_name / completed_at nullablenumber
topup.failed+ failure_code, failure_reason, is_user_fixablefailure_* only herenumber
esim.delivered / .cancelled / .installed / .activated / .depletedid, client_reference, status, activation_status, product_name, country_code, data_amount_gb, validity_days, amount, currency, iccidmost nullable; activation_code always omittednumber
esim.failed+ failure_code, failure_reason, is_user_fixablefailure_* only herenumber
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).

keytypenull when
idnumber (uint64)
client_referencestring | nullclient gave none
statusstring
sub_statusstring— (may be "")
product_namestring
denominationnumber
quantitynumber
amountnumber
delivered_quantitynumber | nullnot yet valid
delivered_onstring (RFC3339) | nullnot yet valid
created_atstring
{
  "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.

keytype
wallet_idnumber
ledger_idnumber
amountstring
currencystring
reference_numberstring
remarksstring (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.

keytypenotes
idnumber
client_referencestring | null
statusstring
product_namestring | nullnull if variant→product lookup fails
variant_namestring | null
amountnumber
currencystring
completed_atstring | nullfrom UpdatedAt
failure_codestring | nulltopup.failed only (INVALID_RECIPIENT, PRODUCT_OUT_OF_STOCK, UNKNOWN, …)
failure_reasonstring | nulltopup.failed only
is_user_fixablebooltopup.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.

keytypenotes
idnumber
client_referencestring | null
statusstringPENDING/DELIVERED/FAILED/CANCELLED
activation_statusstringNOT_INSTALLED/ACTIVE/EXPIRED
product_name / country_codestring | null
data_amount_gbnumber | null
validity_daysnumber | null
amountnumber
currencystring
iccidstring | nullplaintext (stored encrypted); null pre-provision
failure_code / failure_reason / is_user_fixableesim.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 valueesim.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 the whsec_ 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.id

Verify 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; events database/models/webhook.go:119
  • Builders: services/webhook_service.go (TriggerOrderEvent/TriggerWalletEvent/TriggerTopupEvent/TriggerEsimEvent)
  • Signing/headers: services/webhook_sender.go; event id utils/utils.go:275; secret utils/code_generator.go
  • Registration/rotate: http/handler/client_webhooks.go

On this page