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 response doesn't include it.
§ 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 */ }
}
{
"id": 123456,
"name": "subscription.created",
"version": 2,
"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). |
| version | The API version the payload was built in. Only sent from version 2. |
| 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}. |
id values are integers in production today, but treat them as opaque identifiers in your storage layer — store them in a column wide enough to fit a future format change (e.g. a string column or bigint). Don't validate the format and don't assume is_int / typeof === 'number'; just round-trip them as you received them.
§ 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, price, 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 |
| contact.created | A person or company contact is created. Opt-in. | Contact |
| contact.updated | A contact's details or address change. Opt-in. | Contact |
| contact.deleted | A contact is deleted. Opt-in. | Contact |
| 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.
Choosing events
Each endpoint only receives the events selected for it under Developers → Webhooks in the admin UI. Endpoints registered with POST /webhooks/endpoints, and endpoints that existed before events became selectable, receive every event except contact.*. The contact events are opt-in: select them on the endpoint to start receiving them.
§ 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.
The subscription and order examples follow the version selected in the header. In version 2 they carry "version": 2 and embed the donor as contact, including the contact's metadata.
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": 76963688,
"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/…"
}
}
{
"id": 92118,
"name": "subscription.created",
"version": 2,
"account": 42,
"created_at": "2026-04-25T09:30:00+00:00",
"data": {
"id": 76963688,
"contact": {
"id": 1042,
"type": "person",
"name": "Anna Persson",
"email": "[email protected]",
"metadata": { "crm_id": "CRM-1001" },
/* … every other contact field */
},
"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"
}
}
BankID signature. If the customer signed the subscription with BankID and Include BankID signing evidence is turned on in the endpoint's settings in the admin UI, data.bankid_signing carries the signature so you can archive it. signature and ocsp_response are base64, as returned by BankID. Other endpoints never get this key.
"bankid_signing": { "order_ref": "131daac9-16c6-4618-beb0-365768f37288", "signature": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgi…", "ocsp_response": "MIIHfgoBAKCCB3cwggdzBgkrBgEFBQcwAQEEggdkMIIHYDCC…" }
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": 76963688,
"price": 9900,
"currency": "SEK",
"renews_at": "2026-06-25T09:29:51+00:00",
"reference_id": "user_42",
"status": "active"
}
}
{
"id": 92521,
"name": "subscription.renewed",
"version": 2,
"account": 42,
"created_at": "2026-05-25T09:30:00+00:00",
"data": {
"id": 76963688,
"contact": {
"id": 1042,
"type": "person",
"name": "Anna Persson",
"email": "[email protected]",
"metadata": { "crm_id": "CRM-1001" },
/* … every other contact field */
},
"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. A price change also sends it, whether the donor changed the amount in the portal, someone changed it in the admin UI, or a product price update reached the subscription.
{
"id": 92812,
"name": "subscription.updated",
"account": 42,
"created_at": "2026-05-25T09:31:00+00:00",
"data": {
"id": 76963688,
"price": 9900,
"currency": "SEK",
"renews_at": "2026-05-25T09:29:51+00:00",
"reference_id": "user_42",
"status": "delayed"
}
}
{
"id": 92812,
"name": "subscription.updated",
"version": 2,
"account": 42,
"created_at": "2026-05-25T09:31:00+00:00",
"data": {
"id": 76963688,
"contact": {
"id": 1042,
"type": "person",
"name": "Anna Persson",
"email": "[email protected]",
"metadata": { "crm_id": "CRM-1001" },
/* … every other contact field */
},
"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": 76963688,
"price": 9900,
"currency": "SEK",
"renews_at": null,
"ends_at": "2026-06-25T09:29:51+00:00",
"reference_id": "user_42",
"status": "cancelled"
}
}
{
"id": 93001,
"name": "subscription.cancelled",
"version": 2,
"account": 42,
"created_at": "2026-05-26T11:14:00+00:00",
"data": {
"id": 76963688,
"contact": {
"id": 1042,
"type": "person",
"name": "Anna Persson",
"email": "[email protected]",
"metadata": { "crm_id": "CRM-1001" },
/* … every other contact field */
},
"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": 516253755,
"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
}
}
{
"id": 92117,
"name": "order.confirmed",
"version": 2,
"account": 42,
"created_at": "2026-04-25T09:29:55+00:00",
"data": {
"id": 516253755,
"contact": {
"id": 1042,
"type": "person",
"name": "Anna Persson",
"email": "[email protected]",
"metadata": { "crm_id": "CRM-1001" },
/* … every other contact field */
},
"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": 516253755,
"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
}
}
{
"id": 92119,
"name": "order.paid",
"version": 2,
"account": 42,
"created_at": "2026-04-25T09:30:01+00:00",
"data": {
"id": 516253755,
"contact": {
"id": 1042,
"type": "person",
"name": "Anna Persson",
"email": "[email protected]",
"metadata": { "crm_id": "CRM-1001" },
/* … every other contact field */
},
"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]
}
}
contact.created / contact.updated / contact.deleted
Sent when a person or company contact in the account's CRM is created, changed, or deleted, whether through checkout, the admin UI, or the contacts API. data matches GET /contacts/{id}. These events are opt-in; see Choosing events.
- Octany sends contact events once the request or background job that changed the contact has finished, and at most once per contact and event. When one edit saves a contact several times, you get a single event with the final state, and
contact.createdalready includes changes made later in the same request. contact.updatedfires whentype,name,email,phone,personal_identity_number,company_number,parent_contact_id, or the address changes. Other changes, such as custom field values, don't send it.- A change to
metadataalone doesn't sendcontact.updated, so storing your own ID withPATCH /contacts/{id}doesn't trigger a webhook back to you. contact.deletedcarries the contact as it was when it was deleted.- Contact imports, order imports, and reverting an order import don't send contact events. Use
GET /contactsto catch up after a bulk import.
{
"id": 93410,
"name": "contact.updated",
"account": 42,
"created_at": "2026-10-02T09:15:02+00:00",
"data": {
"id": 1042,
"type": "person",
"name": "Anna Persson",
"email": "[email protected]",
"phone": "+46701234567",
"personal_identity_number": "199510272389",
"company_number": null,
"parent_contact_id": 1001,
"address": {
"line1": "Storgatan 1",
"line2": null,
"zip": "411 01",
"city": "Göteborg",
"country": "SE"
},
"metadata": { "crm_id": "CRM-1001" },
"first_seen_at": "2026-10-02T09:12:44.000000Z",
"created_at": "2026-10-02T09:12:44.000000Z",
"updated_at": "2026-10-02T09:15:01.000000Z"
}
}
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.