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}/testsends awebhook.testevent to that endpoint (202): try your receiver.POST /webhooks/{id}/rotate-secretgives a new secret at once (shown once); the old one stops working, so deploy the new one right away.GET /webhooks/{id}/deliveriesshows the deliveries of the last 30 days;POST /webhooks/{id}/deliveries/{deliveryId}/resendsends a finished one again with the same event id (202).
The portal offers all of this under Webhooks.