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.
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.
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ält | Innebörd |
|---|---|
| id | Webhook-leveransens ID. Unikt per leverans, även om den retryas. Använd som dedupe-nyckel. |
| name | Eventtyp (se katalogen). |
| account | Octany-kontot som genererade eventet. |
| created_at | ISO 8601-tidpunkt när Octany genererade eventet. |
| data | Resursen som triggade eventet, transformerad med samma response-transformer som REST-API:et. subscription.*-event har samma form som GET /subscription/{id}. |
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 #
| Event | Skickas när | data är en |
|---|---|---|
| subscription.created | En ny prenumeration aktiveras efter att första ordern är betald. | Subscription |
| subscription.updated | Produkt, betalsätt, status eller annat toppfält ändras. | Subscription |
| subscription.renewed | En förnyelseorder har skapats och (oftast) betalats. | Subscription |
| subscription.cancelled | Avslutad av användaren eller av admin. Kan fortfarande vara aktiv fram till ends_at. | Subscription |
| order.confirmed | Order bekräftad av besökaren — betalningen kan ännu inte ha gått igenom. | Order |
| order.paid | Order:ns state når paid. | Order |
| product.updated | Produktdetaljer ändras. | Product |
| product.deleted | Produkten soft-deletas. | Product |
| product-group.updated | Detaljer på en produktgrupp ändras. | Produktgrupp |
| product-group.deleted | Produktgrupp soft-deletas. | Produktgrupp |
| test.hook | Triggas 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. 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.
{
"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:
- Kort/Swish: bekräftelse och avveckling ligger sekunder ifrån varandra, så
order.confirmedochorder.paidkommer i tät följd. - Faktura-metoder (Billecta, Fortnox, Trustly bankfaktura): ordern bekräftas i kassan, men
order.paidkommer först när fakturan faktiskt är betald — det kan dröja veckor.
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.
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 #
- Octany skickar webhooks asynkront. Leveransen startar oftast strax efter att den utlösande transaktionen gått igenom.
- En leverans räknas som lyckad när din endpoint svarar med HTTP 2xx. Misslyckade leveranser retryas.
- Replays från admin återanvänder originalpayloaden men får ett nytt
id. Användidsom dedupe-nyckel för at-least-once-semantik. - Kör end-to-end-tester via ett staging-konto eller en forwarder som ngrok eller smee.
§ 03.7 Lokal testning #
Två gångbara mönster:
- Forwarda från ett staging-konto. Kör en ngrok-/smee-tunnel från din laptop, registrera URL:en som webhook-endpoint på staging-kontot och trigga checkouts. Riktiga signaturer, riktiga payloads, riktigt retry-beteende.
- Använd
test.hook. Knappen "Send test event" i admin på respektive endpoint skickar en minimaltest.hook-payload — bra för att verifiera att din handler är nåbar, signerad rätt och svarar med 2xx.
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.