Skip to main content
Hopae Connect signs every outgoing webhook delivery with an HMAC-SHA256 signature so you can verify that requests genuinely come from Hopae and have not been tampered with in transit.

How signing works

Each delivery includes an X-Hopae-Signature header with two comma-separated fields:
X-Hopae-Signature: t=<unix-timestamp>,v1=<hex-hmac-sha256>
FieldDescription
tUnix timestamp (seconds) used in the signed payload
v1Hex-encoded HMAC-SHA256 digest
The signed payload is constructed as:
<t>.<raw-request-body>
where <raw-request-body> is the exact UTF-8 bytes of the JSON body received in the HTTP request.

Verifying the signature

const crypto = require('crypto');

function verifyWebhookSignature(req, secret) {
  const sigHeader = req.headers['x-hopae-signature'];
  if (!sigHeader) {
    throw new Error('Missing X-Hopae-Signature header');
  }

  // Parse t= and v1= fields
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => p.split('='))
  );
  const timestamp = parseInt(parts['t'], 10);
  const v1 = parts['v1'];

  if (!timestamp || !v1) {
    throw new Error('Malformed X-Hopae-Signature header');
  }

  // Replay protection: reject if timestamp is older than 5 minutes
  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - timestamp) > 300) {
    throw new Error('Webhook timestamp too old — possible replay attack');
  }

  // Recompute signature using raw body bytes
  // IMPORTANT: use the raw body string, not re-serialised JSON
  const rawBody = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
  const toSign = `${timestamp}.${rawBody}`;
  const expected = crypto.createHmac('sha256', secret).update(toSign).digest('hex');

  // Constant-time comparison to prevent timing attacks
  const expectedBuf = Buffer.from(expected, 'hex');
  const receivedBuf = Buffer.from(v1, 'hex');
  if (expectedBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(expectedBuf, receivedBuf)) {
    throw new Error('Signature mismatch — webhook not authenticated');
  }

  return true;
}
Always use the raw request body bytes for signature verification. Do not parse the JSON and re-stringify it — serialisation order differences will cause the comparison to fail.

End-to-end Express example

Use express.raw() middleware on the webhook route so the handler receives the untouched body. Express’s default JSON parser destroys the exact bytes the signature was computed over.
const express = require('express');
const app = express();

// Your webhook secret from POST /apps/{clientId}/webhook-config/rotate-secret.
// Persist it securely — also available via GET /webhook-config.
const WEBHOOK_SECRET = process.env.HOPAE_WEBHOOK_SECRET;

// Raw body is required for signature verification.
app.post(
  '/webhooks/hopae',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    try {
      verifyWebhookSignature(
        { headers: req.headers, body: req.body.toString('utf8') },
        WEBHOOK_SECRET,
      );
    } catch (err) {
      return res.status(401).json({ error: err.message });
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // handle event.type — e.g. "verification.completed"
    console.log('Verified webhook:', event.eventId, event.event?.type);
    res.status(200).end();
  },
);

app.listen(3000);

Event types

Subscribe to any of these event types via the events array on PATCH /apps/{clientId}/webhook-config. Each delivery includes the event type in the event field of the payload.
Event typeWhen it fires
verification.createdA new verification session has been created.
verification.requestedThe integrator has initiated the verification flow.
verification.workflow.startedThe workflow engine has begun executing nodes.
verification.user.authentication_startedThe end-user has reached the identity provider.
verification.user.authentication_succeededThe identity provider returned a successful authentication result.
verification.user.authentication_failedThe identity provider returned a failed authentication result.
verification.completedThe full verification succeeded end-to-end and claims are available via /verifications/{id}/userinfo.
verification.failedThe verification failed terminally (authentication failed, provider error, or workflow rejection).
verification.workflow.cancelledThe verification was cancelled by the integrator or the end-user.
verification.session.timed_outThe verification session exceeded its configured TTL without reaching a terminal state.
Each payload has the shape:
{
  "event": "verification.completed",
  "eventId": "evt_xxxx",
  "timestamp": "2026-04-09T00:00:00.000Z",
  "apiVersion": "v1",
  "clientId": "FK5b0KSM",
  "data": {
    "verificationId": "64f9dbca2375495aab49fd8cceeeabb2",
    "status": "completed",
    "providerId": "mitid",
    "workflowId": "wf_964850ee",
    "sessionExpiresAt": "2026-04-09T00:15:00.000Z",
    "event": {
      "type": "verification.completed",
      "timestamp": "2026-04-09T00:00:00.000Z",
      "metadata": {}
    },
    "result": {
      "loa": "3",
      "amr": ["pwd", "mfa"],
      "channel": { "type": "oidc", "transport": "redirect" }
    },
    "deviceType": "mobile",
    "countryCode": "DK",
    "countryName": "Denmark",
    "createdAt": "2026-04-09T00:00:00.000Z",
    "updatedAt": "2026-04-09T00:00:00.000Z"
  }
}
The top-level event is a string; the nested data.event is an object with its own type, timestamp, and optional metadata — they carry the same event type value, but data.event.metadata lets you pass extra context through the delivery pipeline.

Obtaining your webhook secret

Webhook secrets are provisioned server-side when you call POST /apps/{client_id}/webhook-config/rotate-secret. Store the secret securely (e.g. as an environment variable). The secret is also available via GET /apps/{clientId}/webhook-config.

Replay protection

The t timestamp in the signature header reflects the moment the delivery was sent. Hopae recommends rejecting webhooks where |now - t| > 300 seconds (5 minutes) to prevent replay attacks, as shown in the examples above.

Backwards compatibility

Apps that have never called rotate-secret do not have a signing secret configured. For those apps, Hopae delivers webhooks without an X-Hopae-Signature header. Call rotate-secret once to enable signing on your app.