# Webhooks

Octany sends signed JSON POSTs to URLs you register on `POST
/api/{account}/webhooks/endpoints`. This doc is the catalog of events,
example payloads, and the verification recipe.

## 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.

> The endpoint secret is **not** returned by `POST
> /webhooks/endpoints` today. Read it from **Developers → Webhooks** in
> the admin UI after creation.

## Envelope

Every payload uses the same shape:

```json
{
  "id": 123456,
  "name": "subscription.created",
  "account": 42,
  "created_at": "2025-04-25T09:30:00+00:00",
  "data": { /* event-specific resource */ }
}
```

- `id` — webhook delivery ID (unique per delivery, even if retried).
- `name` — event type (see catalog below).
- `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. So
  `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 (a string column, or `bigint`).
> Don't validate the format and don't gate on `is_int` /
> `typeof === 'number'` before persisting; round-trip them as received.

## Event catalog

| Event name | Fired when | `data` is a |
| --- | --- | --- |
| `subscription.created` | A new subscription is activated post-payment for its first order. | Subscription |
| `subscription.updated` | A subscription's 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` | A subscription is cancelled by user or admin. May still be active until `ends_at`. | Subscription |
| `order.confirmed` | An order has been confirmed by the visitor — payment may not have settled yet. | Order |
| `order.paid` | An order's `state` reaches `paid`. | Order |
| `product.updated` | A product's details change. | Product |
| `product.deleted` | A product is soft-deleted. | Product |
| `product-group.updated` | A product group's details change. | Product group |
| `product-group.deleted` | A product group is soft-deleted. | Product group |
| `test.hook` | Triggered manually from the admin UI to verify a new endpoint. | `null` / minimal payload |

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.

## 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 to `trialing` /
`future_start` for delayed-start products). 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. Correlate via
`data.reference_id` (the `referenceId` you set on the checkout URL).

```json
{
  "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 — for example, to
extend a "valid until" date on your user record.

```json
{
  "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. `active` → `delayed` while a renewal is being retried, or
`delayed` → `unpaid` after retries are exhausted). If your dunning logic
depends on payment failures, subscribe to this event and react to
`status` transitions; Octany does not emit a per-attempt failure event.

```json
{
  "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 (via API or admin). 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.

```json
{
  "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:

- **Card / Swish:** confirmation and settlement are seconds apart, so
  `order.confirmed` and `order.paid` arrive back-to-back.
- **Invoice methods** (Billecta, Fortnox, Trustly bank invoice): the
  order is confirmed at checkout, but `order.paid` only fires when the
  invoice is actually paid — which can be weeks later.

Use this event if you need to acknowledge the purchase before money has
moved (e.g. "Your order is being processed" emails). Don't use it to
unlock paid features — that's `order.paid` / `subscription.created`.

```json
{
  "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` above). This is the safe trigger
for "the money is in" — fulfilment, license activation, donor receipts.

```json
{
  "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}`.

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

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

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

```json
{
  "id": 0,
  "name": "test.hook",
  "account": 42,
  "created_at": "2026-04-25T09:00:00+00:00",
  "data": null
}
```

## Verifying the signature

Octany signs the **raw request body** with HMAC-SHA256 keyed by the
endpoint secret. Always verify before parsing.

> 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, capture the raw body **before** parsing.

### PHP

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

```php
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)

```js
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)

```python
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
```

## 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`. Treat `id` as the dedupe key for at-least-once semantics.
- Use a staging account or a forwarder such as ngrok or smee for
  end-to-end webhook tests.

## Delivery history

If you need historical audit, the admin UI exposes per-endpoint
deliveries.
