API

Guide 6 af 8

Webhooks

Levano POSTer en signeret JSON-body til din URL når noget sker. Du abonnerer med scopet webhooks:manage/api/v1/webhooks.

Opret et endpoint

curl -sS -X POST "https://app.levano.io/api/v1/webhooks" \
  -H "Authorization: Bearer lev_live_..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "url": "https://example.com/hooks/levano",
    "event_types": ["invoice.issued", "invoice.paid"]
  }'

URL'en skal starte med https://, og event_types skal have mindst ét navn.

Svaret indeholder hemmeligheden i klartekst, og kun denne ene gang, i feltet secret ved siden af data. Gem den et sted din server kan læse den. Ingen senere kald udleverer den igen, så mister du den, opretter du et nyt endpoint.

Hændelserne

Hændelse Sker når
matter.created En sag er oprettet
matter.closed En sag er lukket
invoice.issued En faktura er udstedt eller sendt
invoice.paid En faktura er betalt fuldt ud
document.signed Et underskriftsflow er forseglet
time_entry.created En tidsregistrering er oprettet

KYC-API'en har sit eget sæt (kyc.case.approved, kyc.case.expiring og flere). Se KYC-referencen.

Leveringen

POST /hooks/levano HTTP/1.1
Content-Type: application/json
User-Agent: Levano-Webhook/1.0
Levano-Signature: t=1772539200,v1=6f8b1c...
Levano-Event-Id: 0d0a4f6e-9c1b-4a72-8f5d-31e2b7a90c48
Levano-Event-Type: invoice.paid
Levano-Delivery-Attempt: 1
{
  "id": "0d0a4f6e-9c1b-4a72-8f5d-31e2b7a90c48",
  "event": "invoice.paid",
  "timestamp": "2026-03-01T09:20:00+00:00",
  "organization_id": "5c2e1a44-7b90-4d13-a6f8-c0417e2d8b35",
  "data": {
    "invoice": {
      "id": "9c1f7b30-2a44-4d6e-b1c8-5e0a3f7d9214",
      "status": "paid",
      "total": 1623750,
      "currency": "DKK",
      "matter_id": "3f9a1c02-6d4e-4a8b-9f10-0c2b7d5e8a41",
      "client_id": "b1d0f6a2-7c33-4e51-8a6d-2f9e4c7b1055"
    }
  }
}

Nøglen under data følger hændelsen: matter for de to sagshændelser, invoice for de to fakturahændelser, time_entry og signing_flow for de sidste to.

data bærer identifikatorer og status, ikke hele objektet. Skal du bruge resten, henter du den med din egen nøgle. Det er bevidst: en webhook-body ligger i logs hos begge parter, og den skal ikke være en kopi af sagsdatabasen.

Ethvert 2xx-svar tæller som modtaget. Alt andet udløser genforsøg.

Verificér signaturen først

Levano-Signature har formen t=<unix>,v1=<hex>. v1 er HMAC-SHA256 over strengen {t}.{rå body} med endpointets hemmelighed som nøgle.

Der er tre ting der skal være rigtige, og den første er den der oftest går galt:

  1. Hash de bytes du modtog. Ikke JSON du har parset og serialiseret igen. Nøglerækkefølge, mellemrum og escaping ændrer sig ved reserialisering, og så matcher digestet ikke. Læs den rå body før din framework parser den.
  2. Sammenlign i konstant tid. hash_equals, hmac.compare_digest, crypto.timingSafeEqual. Ikke ==.
  3. Afvis gamle tidsstempler. Vi accepterer 300 sekunders afvigelse i vores egen verifikation. Uden det tjek kan en opsnappet levering afspilles igen når som helst.
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_LEVANO_SIGNATURE'] ?? '';

parse_str(strtr($header, ',', '&'), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$digest = (string) ($parts['v1'] ?? '');

if (abs(time() - $timestamp) > 300) {
    http_response_code(400);
    exit;
}

$expected = hash_hmac('sha256', $timestamp.'.'.$raw, $secret);

if (! hash_equals($expected, $digest)) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);   // først nu er det sikkert at parse
http_response_code(202);
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody skal være en Buffer. I Express: express.raw({ type: 'application/json' }).
export function verify(rawBody: Buffer, header: string, secret: string): boolean {
	const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2) as [string, string]));
	const timestamp = Number(parts.t);

	if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
		return false;
	}

	const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
	const received = Buffer.from(parts.v1 ?? '', 'hex');

	return expected.length === received.length && timingSafeEqual(expected, received);
}

Deduplikér på Levano-Event-Id

Levering er at-least-once. Den samme hændelse kan lande to gange, typisk fordi dit svar blev langsomt nok til at vi gav op og prøvede igen, mens du faktisk var færdig.

Levano-Event-Id (og id i bodyen) er stabil på tværs af genforsøg af samme hændelse. Gem den, og drop en levering du har set før:

INSERT INTO processed_webhooks (event_id, received_at) VALUES (?, NOW())
ON DUPLICATE KEY UPDATE event_id = event_id;

Er din håndtering idempotent i forvejen ("sæt fakturaens status til betalt"), er en dublet harmløs. Er den ikke ("tilføj en betaling"), så er Levano-Event-Id det der redder dig.

Rækkefølgen er heller ikke garanteret. To hændelser for den samme sag kan ankomme byttet om. Byg efter tilstand, ikke efter ankomstorden.

Genforsøg og cirkelafbryder

Op til fem forsøg pr. hændelse, med voksende pause imellem: 30 sekunder, 2 minutter, 8 minutter, 30 minutter. Levano-Delivery-Attempt fortæller hvilket forsøg du sidder med.

Fejler 20 leveringer i træk på det samme endpoint, deaktiveres det (is_active bliver false), og vi holder op med at sende. Det er ikke en straf, men en beskyttelse mod at en død URL fylder køen. Feltet consecutive_failures på endpointet viser hvor tæt du er. Du aktiverer det igen med PATCH /api/v1/webhooks/{id} og {"is_active": true}, når din side er tilbage.

Svar hurtigt, arbejd bagefter

Kvittér med 202 så snart signaturen er verificeret, og læg selve arbejdet i en kø. Vi venter 10 sekunder på svaret (5 på forbindelsen). En handler der kalder tre andre systemer inline er en handler der før eller siden rammer det loft, og så får du genforsøg oveni det arbejde der allerede kørte.

Test uden at vente på en rigtig hændelse

Opret et endpoint mod en opsamlings-URL (en lokal tunnel eller en request bin), og udløs hændelsen i Levano. Der er ingen "send en testhændelse"-knap i v1, og der er ingen sandkasse der genererer syntetiske hændelser. Brug et ikke-produktionsmiljø, hvor en rigtig sag koster ingenting.