Octany. for/devs
§ 03 — Webhooks

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.

Mental model Treat webhooks as the source of truth for "did this payment actually clear?" — never the success-page redirect. Correlate to your own user via the 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.

Where to find the secret Copy the endpoint secret from Developers → Webhooks in the admin UI. The 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 */ }
}
FieldMeaning
idWebhook delivery ID. Unique per delivery, even if retried. Use as dedupe key.
nameEvent type (see catalog).
versionThe API version the payload was built in. Only sent from version 2.
accountThe Octany account that generated the event.
created_atISO 8601 timestamp of when Octany generated the event.
dataThe 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}.
IDs are opaque Subscription, order, and webhook-delivery 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 #

EventFired whendata is a
subscription.createdA new subscription is activated post-payment for its first order.Subscription
subscription.updatedProduct, price, billing method, status, or other top-level field changes.Subscription
subscription.renewedA renewal order has been generated and (typically) paid.Subscription
subscription.cancelledCancelled by user or admin. May still be active until ends_at.Subscription
order.confirmedOrder confirmed by the visitor — payment may not have settled yet.Order
order.paidOrder's state reaches paid.Order
product.updatedProduct details change.Product
product.deletedProduct is soft-deleted.Product
product-group.updatedProduct group details change.Product group
product-group.deletedProduct group is soft-deleted.Product group
contact.createdA person or company contact is created. Opt-in.Contact
contact.updatedA contact's details or address change. Opt-in.Contact
contact.deletedA contact is deleted. Opt-in.Contact
test.hookTriggered 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:

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.

{
  "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.

Watch out The signing input is the JSON body as produced by PHP's 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 #

§ 03.7 Local testing #

Two viable patterns:

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.