Signed events, eventually delivered.
Octany sends signed JSON POSTs to URLs you register on POST /api/{account}/webhooks/endpoints. Eleven event types. HMAC-SHA256 over the raw body. At-least-once delivery. Idempotency is on you.
reference_id you set on the checkout, and dedupe by the delivery id.
§ 03.1 Headers #
User-Agent: Octany/1.0
Content-Type: application/json
Octany-Signature: <hex>
Octany-Signature is hash_hmac('sha256', json_encode($body), $secret) where $secret is the 32-character secret generated for the endpoint when you registered it.
POST /webhooks/endpoints today. Read it from Developers → Webhooks in the admin UI after creation. This is filed for fix.
§ 03.2 Envelope #
Every payload uses the same shape:
{
"id": 123456,
"name": "subscription.created",
"account": 42,
"created_at": "2025-04-25T09:30:00+00:00",
"data": { /* event-specific resource */ }
}
| Field | Meaning |
|---|---|
| id | Webhook delivery ID. Unique per delivery, even if retried. Use as dedupe key. |
| name | Event type (see catalog). |
| account | The Octany account that generated the event. |
| created_at | ISO 8601 timestamp of when Octany generated the event. |
| data | The resource that triggered the event, transformed with the same response transformer used by the REST API. subscription.* events carry the same shape as GET /subscription/{id}. |
§ 03.3 Event catalog #
| Event | Fired when | data is a |
|---|---|---|
| subscription.created | A new subscription is activated post-payment for its first order. | Subscription |
| subscription.updated | Product, billing method, status, or other top-level field changes. | Subscription |
| subscription.renewed | A renewal order has been generated and (typically) paid. | Subscription |
| subscription.cancelled | Cancelled by user or admin. May still be active until ends_at. | Subscription |
| order.confirmed | Order confirmed by the visitor — payment may not have settled yet. | Order |
| order.paid | Order's state reaches paid. | Order |
| product.updated | Product details change. | Product |
| product.deleted | Product is soft-deleted. | Product |
| product-group.updated | Product group details change. | Product group |
| product-group.deleted | Product group is soft-deleted. | Product group |
| test.hook | Triggered manually from the admin UI to verify a new endpoint. | null / minimal |
Event names are stable public identifiers. Treat the values in this table as the contract and map them to your own application events as needed.
§ 03.4 Example payloads #
The data block on each event mirrors what GET on the underlying resource returns. So a subscription.* event carries the same shape as GET /subscription/{id}, an order.* event matches GET /order/{id}, and so on.
subscription.created
Sent once the first order on a new subscription has been paid and the subscription transitions to active (or trialing / future_start). For invoice-billed subscriptions, this fires when the customer actually pays the first invoice — which can be days after checkout. This is the canonical "activate the user" trigger.
{
"id": 92118,
"name": "subscription.created",
"account": 42,
"created_at": "2026-04-25T09:30:00+00:00",
"data": {
"id": "sub_8gKQz4F2",
"price": 9900,
"vat": 2500,
"currency": "SEK",
"created_at": "2026-04-25T09:29:51+00:00",
"renews_at": "2026-05-25T09:29:51+00:00",
"ends_at": null,
"trial_ends_at": null,
"reference_id": "user_42",
"reference_name": "Premium plan – Anna Persson",
"status": "active",
"update_card_url": "https://octanybilling.com/…",
"invoices_url": "https://octanybilling.com/…",
"cancel_url": "https://octanybilling.com/…"
}
}
subscription.renewed
Sent each time a renewal order is generated and (typically) paid. Same payload shape as subscription.created. Use this if you want to track renewals separately from initial activation, e.g. to extend a "valid until" date on your user record.
{
"id": 92521,
"name": "subscription.renewed",
"account": 42,
"created_at": "2026-05-25T09:30:00+00:00",
"data": {
"id": "sub_8gKQz4F2",
"price": 9900,
"currency": "SEK",
"renews_at": "2026-06-25T09:29:51+00:00",
"reference_id": "user_42",
"status": "active"
}
}
subscription.updated
Sent when the subscription's product, billing method, status, or other top-level field changes — including transitions driven by Octany itself (e.g. active → delayed while a renewal is being retried, or delayed → unpaid after retries are exhausted). If your dunning logic depends on payment failures, watch for status transitions on this event — Octany does not emit a per-attempt failure event.
{
"id": 92812,
"name": "subscription.updated",
"account": 42,
"created_at": "2026-05-25T09:31:00+00:00",
"data": {
"id": "sub_8gKQz4F2",
"price": 9900,
"currency": "SEK",
"renews_at": "2026-05-25T09:29:51+00:00",
"reference_id": "user_42",
"status": "delayed"
}
}
subscription.cancelled
Sent at the moment of cancellation. The subscription may still be active until ends_at — i.e. the customer paid through the end of the period and shouldn't lose access yet. Drive your access revocation off ends_at, not the receipt of this event.
{
"id": 93001,
"name": "subscription.cancelled",
"account": 42,
"created_at": "2026-05-26T11:14:00+00:00",
"data": {
"id": "sub_8gKQz4F2",
"price": 9900,
"currency": "SEK",
"renews_at": null,
"ends_at": "2026-06-25T09:29:51+00:00",
"reference_id": "user_42",
"status": "cancelled"
}
}
order.confirmed
Sent immediately when an order is confirmed by the visitor — for both one-off and recurring purchases. Payment may not have settled yet:
- Card / Swish: confirmation and settlement are seconds apart, so
order.confirmedandorder.paidarrive back-to-back. - Invoice methods (Billecta, Fortnox, Trustly bank invoice): the order is confirmed at checkout, but
order.paidonly fires when the invoice is actually paid — possibly weeks later.
Use this event to acknowledge the purchase ("Your order is being processed") — not to unlock paid features. That's order.paid / subscription.created.
{
"id": 92117,
"name": "order.confirmed",
"account": 42,
"created_at": "2026-04-25T09:29:55+00:00",
"data": {
"id": "ord_2xQa9V",
"created_at": "2026-04-25T09:29:55+00:00",
"total": 7920,
"total_with_vat": 9900,
"currency": "SEK",
"reference_id": "user_42",
"reference_name": "Premium plan – Anna Persson",
"state": "confirmed",
"remote_id": null,
"delivery": null
}
}
order.paid
Sent when the order's state reaches paid. Timing depends on the billing method (see order.confirmed). This is the safe trigger for "the money is in" — fulfilment, license activation, donor receipts.
{
"id": 92119,
"name": "order.paid",
"account": 42,
"created_at": "2026-04-25T09:30:01+00:00",
"data": {
"id": "ord_2xQa9V",
"created_at": "2026-04-25T09:29:55+00:00",
"total": 7920,
"total_with_vat": 9900,
"currency": "SEK",
"reference_id": "user_42",
"reference_name": "Premium plan – Anna Persson",
"state": "paid",
"remote_id": "ch_1Ngn7K2eZvKYlo2C",
"delivery": null
}
}
product.updated
Sent when a product is created or updated (via POST /product/{id} or the admin UI). data matches GET /product/{id}.
{
"id": 91002,
"name": "product.updated",
"account": 42,
"created_at": "2026-04-24T14:11:00+00:00",
"data": {
"id": 42,
"name": "Monthly membership",
"type": "Recurring",
"price": 9900,
"currency": "SEK",
"vat_rate": 25,
"interval": "month"
}
}
product.deleted
Sent when a product is soft-deleted. The payload is the product as it was at the time of deletion. Stale references on your side should fall back to read-only display.
{
"id": 91044,
"name": "product.deleted",
"account": 42,
"created_at": "2026-04-24T15:02:00+00:00",
"data": {
"id": 42,
"name": "Monthly membership",
"type": "Recurring"
}
}
product-group.updated / product-group.deleted
Product groups bundle products that should be displayed or sold together (e.g. multi-product checkout tabs). These events fire on the same conditions as the corresponding product.* events. data is the product-group resource.
{
"id": 91120,
"name": "product-group.updated",
"account": 42,
"created_at": "2026-04-24T15:30:00+00:00",
"data": {
"id": 12,
"name": "Membership tiers",
"products": [42, 43, 44]
}
}
test.hook
Triggered manually from Developers → Webhooks → Send test event in the admin UI. Lets you verify reachability and signature handling without producing a real subscription or order. Payload is minimal — treat anything beyond name === "test.hook" as best-effort.
{
"id": 0,
"name": "test.hook",
"account": 42,
"created_at": "2026-04-25T09:00:00+00:00",
"data": null
}
§ 03.5 Verifying signatures #
Octany signs the raw request body with HMAC-SHA256 keyed by the endpoint secret. Always verify before parsing.
json_encode($data). If you proxy the request through middleware that reformats JSON (logging, validation, schema enforcement), capture the raw body before parsing — otherwise re-serialization will change byte-equality and break verification.
PHP
function octany_verify(string $rawBody, string $signature, string $secret): bool { $expected = hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $signature); } $raw = file_get_contents('php://input'); $sig = $_SERVER['HTTP_OCTANY_SIGNATURE'] ?? ''; if (! octany_verify($raw, $sig, getenv('OCTANY_WEBHOOK_SECRET'))) { http_response_code(401); exit; } $event = json_decode($raw, true);
Laravel
Route::post('/octany/webhook', function (Request $request) { $expected = hash_hmac('sha256', $request->getContent(), config('services.octany.webhook_secret')); abort_unless( hash_equals($expected, (string) $request->header('Octany-Signature')), 401 ); OctanyEventReceived::dispatch($request->json()->all()); return response()->noContent(); });
Node.js (Express)
import express from 'express' import crypto from 'node:crypto' const app = express() app.post( '/octany/webhook', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.header('octany-signature') || '' const expected = crypto .createHmac('sha256', process.env.OCTANY_WEBHOOK_SECRET) .update(req.body) .digest('hex') if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) { return res.sendStatus(401) } const event = JSON.parse(req.body.toString('utf8')) // … dispatch by event.name res.sendStatus(204) } )
Python (Flask)
import hmac, hashlib, os from flask import Flask, request, abort app = Flask(__name__) @app.post("/octany/webhook") def webhook(): raw = request.get_data() expected = hmac.new( os.environ["OCTANY_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, request.headers.get("Octany-Signature", "")): abort(401) event = request.get_json() # … dispatch by event["name"] return "", 204
§ 03.6 Delivery semantics #
- Octany dispatches webhooks asynchronously. Delivery usually starts shortly after the originating transaction settles.
- A delivery is treated as successful when your endpoint returns HTTP 2xx. Failed deliveries are retried.
- Replays from the admin UI re-use the original payload but get a new
id. Treatidas the dedupe key for at-least-once semantics. - Use a staging account or a forwarder such as ngrok or smee for end-to-end tests.
§ 03.7 Local testing #
Two viable patterns:
- Forward from a staging account. Run an ngrok / smee tunnel from your laptop, register that URL as a webhook endpoint on the staging account, and trigger checkouts. Real signatures, real payloads, real retry behavior.
- Use
test.hook. The admin UI's "Send test event" button on each endpoint dispatches a minimaltest.hookpayload — useful to verify that your handler is reachable, signed correctly, and 2xx-ing.
For unit tests, capture a real payload from staging once, store it as a fixture, and replay it against your handler with the corresponding signature computed from your test secret.