Octany. for/devs
§ 03 — Webhooks

Signerade event, levererade till slut.

Octany skickar signerade JSON-POST:er till URL:er du registrerar via POST /api/{account}/webhooks/endpoints. Elva eventtyper. HMAC-SHA256 över råa bodyn. At-least-once-leverans. Idempotensen sköter du själv.

Tänk så här Behandla webhooks som sanningskälla för "gick betalningen igenom?" — aldrig success-redirecten. Koppla till din egen användare via det reference_id du satt på checkouten, och deduplicera på leverans-id.

§ 03.1 Headers #

User-Agent:        Octany/1.0
Content-Type:      application/json
Octany-Signature:  <hex>

Octany-Signature är hash_hmac('sha256', json_encode($body), $secret) där $secret är den 32-teckenshemlighet som genererades för endpointen när du registrerade den.

Känd lucka Endpoint-hemligheten returneras inte från POST /webhooks/endpoints idag. Plocka den från Developers → Webhooks i admin när endpointen är skapad. Det ligger som en bugg att fixa.

§ 03.2 Envelope #

Alla payloads har samma form:

{
  "id": 123456,
  "name": "subscription.created",
  "account": 42,
  "created_at": "2025-04-25T09:30:00+00:00",
  "data": { /* event-specifik resurs */ }
}
FältInnebörd
idWebhook-leveransens ID. Unikt per leverans, även om den retryas. Använd som dedupe-nyckel.
nameEventtyp (se katalogen).
accountOctany-kontot som genererade eventet.
created_atISO 8601-tidpunkt när Octany genererade eventet.
dataResursen som triggade eventet, transformerad med samma response-transformer som REST-API:et. subscription.*-event har samma form som GET /subscription/{id}.
ID:n är opaka id-värdena på Subscription, Order och webhook-leveranser är heltal i produktion idag, men hantera dem som opaka identifierare i lagringen — använd en kolumn som rymmer en framtida formatändring (t.ex. en sträng eller bigint). Validera inte formatet och anta inte is_int/typeof === 'number'; skicka tillbaka dem precis som du fick dem.

§ 03.3 Eventkatalog #

EventSkickas närdata är en
subscription.createdEn ny prenumeration aktiveras efter att första ordern är betald.Subscription
subscription.updatedProdukt, betalsätt, status eller annat toppfält ändras.Subscription
subscription.renewedEn förnyelseorder har skapats och (oftast) betalats.Subscription
subscription.cancelledAvslutad av användaren eller av admin. Kan fortfarande vara aktiv fram till ends_at.Subscription
order.confirmedOrder bekräftad av besökaren — betalningen kan ännu inte ha gått igenom.Order
order.paidOrder:ns state når paid.Order
product.updatedProduktdetaljer ändras.Product
product.deletedProdukten soft-deletas.Product
product-group.updatedDetaljer på en produktgrupp ändras.Produktgrupp
product-group.deletedProduktgrupp soft-deletas.Produktgrupp
test.hookTriggas manuellt från admin för att verifiera en ny endpoint.null / minimal

Eventnamnen är stabila publika identifierare. Se värdena i tabellen som kontraktet och mappa dem mot dina egna applikationsevent vid behov.

§ 03.4 Exempel-payloads #

data-blocket på varje event speglar det som GET mot underliggande resurs returnerar. Ett subscription.*-event har alltså samma form som GET /subscription/{id}, ett order.*-event matchar GET /order/{id}, osv.

subscription.created

Skickas när första ordern på en ny prenumeration har betalats och prenumerationen går över i active (eller trialing/future_start). För fakturabetalningar triggas eventet när kunden faktiskt betalar första fakturan — vilket kan vara dagar efter checkouten. Det är den här triggern du ska använda för att aktivera användaren.

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

Skickas varje gång en förnyelseorder skapas och (oftast) betalas. Samma payload-form som subscription.created. Använd det om du vill spåra förnyelser för sig — t.ex. för att förlänga ett "giltigt till"-datum på din användarpost.

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

Skickas när prenumerationens produkt, betalsätt, status eller något annat toppfält ändras — även övergångar som Octany själv driver (t.ex. activedelayed medan en förnyelse retryas, eller delayedunpaid när alla retries är slut). Hänger din dunning-logik på betalningsfel är det status-övergångar i det här eventet du ska bevaka — Octany skickar inget separat event per misslyckat försök.

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

Skickas direkt vid avslut. Prenumerationen kan fortfarande vara active fram till ends_at — kunden har ju betalat hela perioden och ska inte tappa åtkomsten direkt. Stäng åtkomsten utifrån ends_at, inte från att eventet kommer in.

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

Skickas direkt när besökaren bekräftar en order — både för engångsköp och förnyelser. Betalningen behöver inte ha gått igenom ännu:

Använd eventet för att kvittera köpet ("Din order behandlas") — inte för att låsa upp betalda funktioner. För det är det order.paid/subscription.created som gäller.

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

Skickas när orderns state når paid. Tidpunkten beror på betalsätt (se order.confirmed). Det är det här eventet du ska lyssna på när "pengarna är inne" — utleverans, licensaktivering, gåvokvitto.

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

Skickas när en produkt skapas eller uppdateras (via POST /product/{id} eller admin). data matchar GET /product/{id}.

{
  "id": 91002,
  "name": "product.updated",
  "account": 42,
  "created_at": "2026-04-24T14:11:00+00:00",
  "data": {
    "id": 42,
    "name": "Månadsmedlemskap",
    "type": "Recurring",
    "price": 9900,
    "currency": "SEK",
    "vat_rate": 25,
    "interval": "month"
  }
}

product.deleted

Skickas när en produkt soft-deletas. Payloaden är produkten så som den såg ut vid borttagningen. Gamla referenser hos dig bör falla tillbaka på read-only-visning.

{
  "id": 91044,
  "name": "product.deleted",
  "account": 42,
  "created_at": "2026-04-24T15:02:00+00:00",
  "data": {
    "id": 42,
    "name": "Månadsmedlemskap",
    "type": "Recurring"
  }
}

product-group.updated / product-group.deleted

Produktgrupper buntar ihop produkter som ska visas eller säljas tillsammans (t.ex. flikar i en multi-produkt-checkout). Eventen skickas på samma villkor som motsvarande product.*-event. data är produktgrupp-resursen.

{
  "id": 91120,
  "name": "product-group.updated",
  "account": 42,
  "created_at": "2026-04-24T15:30:00+00:00",
  "data": {
    "id": 12,
    "name": "Medlemsnivåer",
    "products": [42, 43, 44]
  }
}

test.hook

Triggas manuellt från Developers → Webhooks → Send test event i admin. Då kan du kontrollera att endpointen är nåbar och att signaturhanteringen funkar — utan att behöva skapa en riktig prenumeration eller order. Payloaden är minimal — räkna inte med något utöver name === "test.hook".

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

§ 03.5 Verifiera signaturer #

Octany signerar den råa request-bodyn med HMAC-SHA256 och endpoint-hemligheten som nyckel. Verifiera alltid innan du parsar.

Tänk på Det som signeras är JSON-bodyn så som PHP:s json_encode($data) producerar den. Om du skickar requesten genom middleware som omformar JSON (loggning, validering, schemavalidering) — läs av råa bodyn innan parsning, annars ändras byte-likheten av re-serialiseringen och verifieringen slår fel.

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'))
    // … dispatcha på 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()
    # … dispatcha på event["name"]
    return "", 204

§ 03.6 Leveranssemantik #

§ 03.7 Lokal testning #

Två gångbara mönster:

För enhetstester: fånga en riktig payload från staging en gång, spara den som fixture och spela upp mot din handler med rätt signatur uträknad från din test-secret.