Octany. for/devs
§ 03 — Webhooks

Webhooks

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.

Här hittar du hemligheten Kopiera endpoint-hemligheten från Developers → Webhooks i admin. Svaret från POST /webhooks/endpoints innehåller den inte.

§ 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 */ }
}
{
  "id": 123456,
  "name": "subscription.created",
  "version": 2,
  "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).
versionAPI-versionen som payloaden byggdes i. Skickas bara från version 2.
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 opaque id-värdena på Subscription, Order och webhook-leveranser är heltal i produktion idag, men hantera dem som opaque identifiers 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, belopp, 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
contact.createdEn person- eller företagskontakt skapas. Opt-in.Contact
contact.updatedEn kontakts uppgifter eller adress ändras. Opt-in.Contact
contact.deletedEn kontakt raderas. Opt-in.Contact
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.

Välja event

Varje endpoint får bara de event som valts för den under Developers → Webhooks i admin. Endpoints som registreras med POST /webhooks/endpoints, och endpoints som fanns innan event gick att välja, får alla event utom contact.*. Kontakteventen är opt-in: välj dem på endpointen för att börja ta emot dem.

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

Exemplen för prenumerationer och ordrar följer versionen som är vald i headern. I version 2 har de "version": 2 och bäddar in givaren som contact, inklusive kontaktens metadata.

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/…"
  }
}
{
  "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" },
      /* … övriga kontaktfält */
    },
    "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-signatur. Om kunden signerade prenumerationen med BankID och Inkludera BankID-signeringsunderlag är påslaget i endpointens inställningar i admin, innehåller data.bankid_signing signaturen så att du kan arkivera den. signature och ocsp_response är base64, precis som BankID returnerar dem. Andra endpoints får aldrig den här nyckeln.

"bankid_signing": {
  "order_ref": "131daac9-16c6-4618-beb0-365768f37288",
  "signature": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgi…",
  "ocsp_response": "MIIHfgoBAKCCB3cwggdzBgkrBgEFBQcwAQEEggdkMIIHYDCC…"
}

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"
  }
}
{
  "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" },
      /* … övriga kontaktfält */
    },
    "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. active → delayed medan en förnyelse retryas, eller delayed → unpaid 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. En beloppsändring skickar det också, oavsett om givaren ändrade beloppet i portalen, någon ändrade det i admin eller en prisändring på produkten nådde prenumerationen.

{
  "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" },
      /* … övriga kontaktfält */
    },
    "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"
  }
}
{
  "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" },
      /* … övriga kontaktfält */
    },
    "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
  }
}
{
  "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" },
      /* … övriga kontaktfält */
    },
    "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
  }
}
{
  "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" },
      /* … övriga kontaktfält */
    },
    "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]
  }
}

contact.created / contact.updated / contact.deleted

Skickas när en person- eller företagskontakt i kontots CRM skapas, ändras eller raderas, oavsett om det sker i checkouten, i admin eller via kontakt-API:et. data matchar GET /contacts/{id}. Eventen är opt-in; se Välja event.

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

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.