> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hopae.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Signature Verification

> Verify incoming webhook payloads using HMAC-SHA256 signatures.

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:

```
<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

<CodeGroup>
  ```javascript Node.js theme={null}
  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;
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  def verify_webhook_signature(headers: dict, raw_body: bytes, secret: str) -> bool:
      sig_header = headers.get('x-hopae-signature') or headers.get('X-Hopae-Signature')
      if not sig_header:
          raise ValueError('Missing X-Hopae-Signature header')

      parts = dict(p.split('=', 1) for p in sig_header.split(','))
      timestamp = int(parts.get('t', 0))
      v1 = parts.get('v1', '')

      # Replay protection: reject if older than 5 minutes
      if abs(time.time() - timestamp) > 300:
          raise ValueError('Webhook timestamp too old — possible replay attack')

      to_sign = f"{timestamp}.{raw_body.decode('utf-8')}"
      expected = hmac.new(secret.encode('utf-8'), to_sign.encode('utf-8'), hashlib.sha256).hexdigest()

      if not hmac.compare_digest(expected, v1):
          raise ValueError('Signature mismatch — webhook not authenticated')

      return True
  ```
</CodeGroup>

<Warning>
  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.
</Warning>

## 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.

```javascript theme={null}
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:

```json theme={null}
{
  "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](/api-reference/workspace/rotate-webhook-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.
