Octany. for/devs view as .md
§ 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.

Known gap The endpoint secret is not returned by 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 */ }
}
FieldMeaning
idWebhook delivery ID. Unique per delivery, even if retried. Use as dedupe key.
nameEvent type (see catalog).
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, 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
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.

§ 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": 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/…"
  }
}

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"
  }
}

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. activedelayed while a renewal is being retried, or delayedunpaid 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": 76963688,
    "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"
  }
}

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
  }
}

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
  }
}

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.

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.