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>
| Field | Description |
|---|
t | Unix timestamp (seconds) used in the signed payload |
v1 | Hex-encoded HMAC-SHA256 digest |
The signed payload is constructed as:
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 type | When it fires |
|---|
verification.created | A new verification session has been created. |
verification.requested | The integrator has initiated the verification flow. |
verification.workflow.started | The workflow engine has begun executing nodes. |
verification.user.authentication_started | The end-user has reached the identity provider. |
verification.user.authentication_succeeded | The identity provider returned a successful authentication result. |
verification.user.authentication_failed | The identity provider returned a failed authentication result. |
verification.completed | The full verification succeeded end-to-end and claims are available via /verifications/{id}/userinfo. |
verification.failed | The verification failed terminally (authentication failed, provider error, or workflow rejection). |
verification.workflow.cancelled | The verification was cancelled by the integrator or the end-user. |
verification.session.timed_out | The 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.