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

# Webhooks

> Receive verification and activation events, and verify their signatures.

Hopae sends an HTTP `POST` to your endpoint when a verification or a connection activation changes. Webhooks carry no personal data: fetch the result from userinfo with your app credentials.

## Set up

In the Console, open **Developers → Webhooks**. An app has two endpoints, one per payload format:

| Endpoint | Payload | Use |
| :- | :- | :- |
| **Webhook v2** | The v2 format on this page | New integrations |
| **Webhook v1** | The [v1 format](/guides/webhook-signing), unchanged | Existing v1 receivers |

Every event goes to each enabled endpoint in that endpoint's format, so with both enabled you receive it on both. Each endpoint has its own **Signing Secret**, in sandbox and production apps alike.

<Note>
  For `qr` and `push` verifications created with the REST API, events follow your status polls. If you stop polling, no terminal event is sent. See [REST API Integration](/v2/guides/api-integration#4-poll-until-finished).
</Note>

## Events

| Event | When |
| :- | :- |
| `verification.initiated` | The verification was created |
| `verification.started` | The user started at the provider (only when the provider reports it) |
| `verification.succeeded` | Completed. Fetch the result from userinfo |
| `verification.failed` | Failed. `data.error.code` says why |
| `verification.cancelled` | Cancelled. `data.cancelledBy` is `client` or `user` |
| `activation.activated` | A connection activation was approved |
| `activation.rejected` | A connection activation was rejected. `data.rejection.reason` says why |

A verification sends at most one terminal event. There is **no expiry event**: a verification that has not finished by its `expiresAt` has expired. See [Expiry](/v2/guides/verifications/verification-flow#expiry).

## Payload

Every event has the same envelope: `id`, `type`, `apiVersion` (`v2`), `occurredAt`, `environment` (`sandbox` or `production`), `appId`, and `data`.

* `id` is the same on every redelivery of an event. Use it to deduplicate.
* Verification events always carry `data.verificationId`, `workflowId`, `connection`, and `createdAt`.

<Accordion title="Example payloads">
  ```json verification.succeeded theme={null}
  {
    "id": "evt_wpzjo4lgjljz3sloadupxpq64j",
    "type": "verification.succeeded",
    "apiVersion": "v2",
    "occurredAt": "2026-09-29T17:01:49.797Z",
    "environment": "sandbox",
    "appId": "sb_k7g0p7by",
    "data": {
      "verificationId": "01a0ee1d-8f53-79f3-b7ec-39447dae7ff5",
      "workflowId": "wf_01M3Q1TDE2J65BPPMFZXH3MHA9",
      "connection": {
        "connectionId": "co-runt-bio-match",
        "credentialId": "co-runt-bio-match",
        "connectionInstanceId": "conn_01M3Q1TDE2R58D3RJ2CM49V58R",
        "provider": { "id": "co-runt", "name": "RUNT" }
      },
      "device": { "type": "desktop" },
      "createdAt": "2026-09-29T17:01:49.779Z",
      "result": {
        "channel": { "type": "centralized_idp", "transport": "internet" }
      },
      "attributes": {
        "requested": ["name", "given_name", "family_name", "document_number", "source_id"],
        "provided": ["name", "given_name", "family_name", "document_number", "source_id"],
        "missing": []
      }
    }
  }
  ```

  ```json activation.activated theme={null}
  {
    "id": "evt_bjudhaqj7ulij5lfqtnpn4buyh",
    "type": "activation.activated",
    "apiVersion": "v2",
    "occurredAt": "2026-09-29T17:02:15.066Z",
    "environment": "production",
    "appId": "11km4n0l",
    "data": {
      "activationInstanceId": "act_01M3Q1VWGS8GA7VQ13RVAXRCR0",
      "provider": { "id": "ng-vid", "name": "Nigeria Voter ID" },
      "connections": [
        {
          "connectionId": "ng-vid",
          "credentialId": "ng-vid",
          "connectionInstanceId": "conn_01M3Q1VWGVR5JCJDBKJ3V7HCW5"
        }
      ]
    }
  }
  ```
</Accordion>

### Headers

| Header | Value |
| :- | :- |
| `X-Hopae-Event-Id` | The event `id` |
| `X-Hopae-Event-Type` | The event `type` |
| `X-Hopae-Timestamp` | Unix seconds at send time |
| `X-Hopae-Signature` | `t=<unix-seconds>,v1=<hex>`, only when the endpoint has a signing secret |
| `User-Agent` | `Hopae-Connect-Webhook/1.0` |

## Signing secret

Under **Developers → Webhooks**, find the endpoint's **Signing Secret**:

* **Rotate secret** (rotate icon) creates or replaces the secret. Store it on your server, for example as an environment variable.
* **Remove secret** (trash icon) stops signing.

An endpoint without a secret sends unsigned deliveries.

## Verify the signature

The signature is an HMAC-SHA256, keyed with the endpoint's secret, over `<t>.<raw request body>`. Recompute it and compare. Reject deliveries whose `t` is more than 5 minutes old.

<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');
    }

    // Use the raw body exactly as received, never re-serialized JSON
    if (typeof req.body !== 'string') throw new Error('Pass the raw request body');
    const rawBody = 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>
  Use the **raw request body bytes**. Parsing the JSON and serializing it again changes the bytes and breaks the comparison.
</Warning>

In Express, use `express.raw({ type: 'application/json' })` on the webhook route and call `verifyWebhookSignature({ headers: req.headers, body: req.body.toString('utf8') }, secret)`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.