Skip to content

Webhooks and signature verification

Register an HTTPS endpoint (key with scope admin); the signing secret is shown once:

curl -s -X POST $BASE/webhooks -H "Authorization: Bearer $ADMIN_KEY" \
  -H 'Content-Type: application/json' -d '{"url":"https://example.com/einvoice-events"}'

Events: invoice.issued, delivery.updated, document.received. Choose them per endpoint with "events": ["invoice.issued"] (omitted or empty: all); change URL or events later with PATCH /webhooks/{id}. Body: {"id": "…", "type": "delivery.updated", "createdAt": "…", "data": {"deliveryId": "…", "status": "DELIVERED", "livemode": "true"}}. livemode is "false" for sandbox events.

Answer with any 2xx within 10 seconds. Failures are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. Events may arrive twice or out of order: deduplicate by id.

Verify every request

Each request has Einvoice-Timestamp (Unix seconds) and Einvoice-Signature: v1=<hex>: the HMAC-SHA256 of <timestamp>.<raw body> with your secret. Compare in constant time and refuse timestamps older than five minutes. Use the raw body, before any JSON parsing.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(raw, ts, sig, secret) {
  if (!secret || !/^\d{1,12}$/.test(ts ?? '') || !/^v1=[0-9a-fA-F]{64}$/.test(sig ?? '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const mac = createHmac('sha256', secret).update(`${ts}.`).update(raw).digest();
  return timingSafeEqual(mac, Buffer.from(sig.slice(3), 'hex'));
}

PHP

function verify(string $raw, ?string $ts, ?string $sig, string $secret): bool {
    if ($secret === '' || preg_match('/^\d{1,12}$/D', $ts ?? '') !== 1
        || preg_match('/^v1=([0-9a-fA-F]{64})$/D', $sig ?? '', $m) !== 1
        || abs(time() - (int) $ts) > 300) return false;
    return hash_equals(hash_hmac('sha256', $ts . '.' . $raw, $secret), strtolower($m[1]));
}

Python

import hashlib, hmac, re, time
def verify(raw: bytes, ts: str | None, sig: str | None, secret: str) -> bool:
    if not secret or not re.fullmatch(r"\d{1,12}", ts or "") or not re.fullmatch(r"v1=[0-9a-fA-F]{64}", sig or ""):
        return False
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig[3:].lower())

Java

static boolean verify(byte[] raw, String ts, String sig, String secret) throws GeneralSecurityException {
    if (secret == null || secret.isEmpty() || ts == null || sig == null
            || !ts.matches("\\d{1,12}") || !sig.matches("v1=[0-9a-fA-F]{64}")
            || Math.abs(Instant.now().getEpochSecond() - Long.parseLong(ts)) > 300) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((ts + ".").getBytes(StandardCharsets.UTF_8));
    return MessageDigest.isEqual(mac.doFinal(raw), HexFormat.of().parseHex(sig.substring(3)));
}

Refuse the request if the secret is not configured: an empty secret would accept forged signatures. The SDKs will ship this as a helper (verifyWebhook, Webhooks::verify, Webhooks.verify, Webhooks.Verify) once they are published.

Manage endpoints

  • POST /webhooks/{id}/test sends a webhook.test event to that endpoint (202): try your receiver.
  • POST /webhooks/{id}/rotate-secret gives a new secret at once (shown once); the old one stops working, so deploy the new one right away.
  • GET /webhooks/{id}/deliveries shows the deliveries of the last 30 days; POST /webhooks/{id}/deliveries/{deliveryId}/resend sends a finished one again with the same event id (202).

The portal offers all of this under Webhooks.