Developers
Åbn Levano

Guide 6 af 9

På denne side

Webhooks

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

Opret et endpoint

bash
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
signing_flow.sent Et underskriftsflow er sendt til sine underskrivere
signing_flow.declined En underskriver har afvist
signing_flow.cancelled Et underskriftsflow er annulleret
document.signed Et underskriftsflow er forseglet
time_entry.created En tidsregistrering er oprettet

De fire signeringshændelser dækker to forskellige ting. De tre signing_flow.* følger flowets liv, mens document.signed betyder forseglet, altså at den underskrevne PDF nu findes og kan hentes. Abonnér på livscyklussen og på forseglingen hver for sig, og læs ikke document.signed som "nogen skrev under".

Der er ingen hændelse når en frist løber ud. expires_at er en dato på flowet, ikke noget der sker, så vil du reagere på den, læser du expires_at og status. Se Underskrifter.

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

Leveringen

http
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
json
{
  "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 for tidsregistreringen og signing_flow for alle fire signeringshændelser. På de tre signing_flow.* bærer den id, status og matter_id, og ved siden af den en recipient_id når ændringen kom fra én underskriver.

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.
php
$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);
ts
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:

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

Søg i dokumentationen

Søg på titel eller emne. Brug Tab til at vælge et resultat.