Webhooks

What eIDAS Pro sends to your webhook endpoint, how to verify the signature, how retries work, and why you should deduplicate on the event id.

Last updated: 2026-10-05

A webhook is an HTTPS POST that eIDAS Pro sends to your endpoint when a verification session changes state. Webhooks come with the Scale tier and need an active subscription. A merchant on a lower tier, or whose Scale subscription is past due or cancelled, gets no deliveries.

What a delivery looks like

POST /your/webhook/endpoint HTTP/1.1
Content-Type: application/json
X-Webhook-ID: <your webhook id>
X-Webhook-Event-ID: evt_3f1c2a9e-8b7d-4c5e-9f10-2a3b4c5d6e7f
X-Webhook-Attempt: 1
X-Webhook-Signature: t=1791014400,v1=5f2b…

{
  "id": "evt_3f1c2a9e-8b7d-4c5e-9f10-2a3b4c5d6e7f",
  "eventType": "verification.success",
  "timestamp": "2026-10-02T09:20:00.000Z",
  "data": { "sessionId": "…", "verificationId": "…" }
}

The event types are verification.started, verification.success, verification.failed and verification.expired. Answer with any 2xx status within 10 seconds. Any other status, a timeout or a connection error counts as a failed attempt.

Your endpoint must be a public https:// URL on port 443. Addresses on private, loopback or link-local networks are refused, both when you save the webhook and at every delivery. eIDAS Pro does not follow redirects: a 3xx answer counts as a failed attempt. Response bodies over 64 KiB are truncated.

Verifying webhook signatures

Check the signature before you trust the body. The X-Webhook-Signature header has two parts:

  • t: the Unix time, in seconds, when this attempt was signed.
  • v1: the hex HMAC-SHA256 of the string "<t>.<raw request body>". The key is the signing secret you were shown once, when you created the webhook.

Compute the HMAC over the raw request body, byte for byte. If you parse the JSON and serialise it again, the key order or whitespace can change and the check will fail.

Reject any delivery whose t is more than 300 seconds away from your own clock, in either direction. Because the timestamp is part of what is signed, this stops someone who captured a delivery from replaying it later. Keep your server clock in sync with NTP.

import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 300;

function verifySignature(rawBody, header, secret) {
    const pairs = header.split(",").map((part) => part.trim().split("="));
    const t = Number(pairs.find(([key]) => key === "t")?.[1]);
    const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value);
    if (!Number.isInteger(t) || signatures.length === 0) return false;
    if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

    const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
    return signatures.some((signature) => {
        const actual = Buffer.from(signature, "hex");
        return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
    });
}

const app = express();

// express.raw keeps the exact bytes that were signed.
app.post("/webhooks/eidas-pro", express.raw({ type: "application/json" }), (req, res) => {
    const rawBody = req.body.toString("utf8");
    if (!verifySignature(rawBody, req.get("X-Webhook-Signature") ?? "", process.env.EIDAS_WEBHOOK_SECRET)) {
        return res.status(400).send("invalid signature");
    }

    const event = JSON.parse(rawBody);
    // Deduplicate on event.id before acting, then answer quickly.
    res.sendStatus(200);
});

Compare signatures in constant time, as timingSafeEqual does above. A plain === lets an attacker learn how much of the signature they got right from how long the comparison takes. The header can hold more than one v1 entry. Accept the delivery if any of them matches.

Retries and deduplication

If an attempt fails, eIDAS Pro tries again about 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours later. Each wait varies a little, so deliveries that failed together do not all retry at the same moment. After six attempts in total, eIDAS Pro stops trying. Retries also stop if the webhook is disabled or deleted, or if the account stops qualifying for webhooks.

Deduplicate on the event id

A delivery can arrive more than once. One example: your endpoint processes an event but times out before it answers. Every retry carries the same body and the same id (also in X-Webhook-Event-ID). Only t, v1 and X-Webhook-Attempt change. Store the ids you have handled and ignore any id you see again.

Deliveries can also arrive out of order. If the order matters to you, read the event's timestamp field, not the time it reached you.