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

# Identity Link Webhooks: Verification Lifecycle Events

> Receive real-time notifications on your server for every step of the Identity Link verification process.

Webhooks let your backend receive real-time HTTP notifications as a person moves through an Identity Link verification. Instead of polling, you register a URL and VU POSTs a signed JSON payload whenever a step completes or fails.

<Note>
  Webhook endpoints are part of the API, so they need a **Premium or Pro** plan — the same requirement as `POST /oauth/token`. See [Plans](/en/identity-link/plans).
</Note>

## Register an endpoint

```bash theme={null}
curl -X POST https://api.identity.vu-one.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/vu",
    "description": "Production verification events"
  }'
```

The request body accepts exactly two fields: `url` (required) and `description` (optional). Anything else is rejected. The call answers `201`:

```json theme={null}
{
  "_id": "b6b0a1f2-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
  "userId": "9f8e7d6c-5b4a-4938-2716-0f5e4d3c2b1a",
  "description": "Production verification events",
  "url": "https://your-app.com/webhooks/vu",
  "createdAt": "2026-08-30T14:02:11.000Z",
  "updatedAt": "2026-08-30T14:02:11.000Z"
}
```

<Warning>
  **The response carries no secret.** Signing is not per endpoint — there is one signing secret per account, and every endpoint you register receives payloads signed with it.
</Warning>

## Where the signing secret lives

Your `webhookSecret` is a 256-bit value shown in the app under **Settings → Developers**, next to your Client Id and Client Secret. The same value comes back from an authenticated `GET /users/me`.

To rotate it, use **Refresh** in that screen or call:

```bash theme={null}
curl -X PATCH https://api.identity.vu-one.com/api/v1/users/me/webhook-secret \
  -H "Authorization: Bearer <token>"
```

The response contains the new secret. Rotation takes effect immediately, so deploy the new value to your verifier before you rotate, or accept a short window of rejected deliveries.

## Events

Twelve events cover the lifecycle. `started` fires when a step begins, `successful` when it clears, `failed` when it is definitively lost.

| Event | When it fires |
| - | - |
| `verification.started` | The flow began — the person opened the link and started the operation. Also fires on the adaptive selfie-only branch. |
| `verification.addfront.started` | The front of the document was submitted for processing. |
| `verification.addfront.successful` | The front was captured and read successfully. |
| `verification.addfront.failed` | The front failed anti-spoofing **and no retries remain**. |
| `verification.addback.started` | The back of the document was submitted for processing. |
| `verification.addback.successful` | The back was captured and read successfully. |
| `verification.addback.failed` | The back failed anti-spoofing **and no retries remain**. |
| `verification.register.started` | The selfie was submitted for liveness and facial comparison. |
| `verification.register.successful` | Liveness and facial comparison succeeded. |
| `verification.register.failed` | Liveness or facial comparison failed **and no retries remain**. |
| `verification.successful` | The whole flow finished successfully. |
| `verification.failed` | The verification failed and will not recover. |

<Tip>
  A failed attempt with retries left produces **no** webhook — the person is simply asked to try again, and `remainingRetries` drops by one. The `*.failed` events mark the exhausted attempt, which is also the point where the link is consumed.
</Tip>

## Payload

Every payload has the same two-key shape: a `type` and a `data` object.

```json theme={null}
{
  "type": "verification.addfront.successful",
  "data": {
    "_verificationId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "_externalUserId": "customer-12345",
    "_metadata": { "campaign": "quickstart" }
  }
}
```

`_externalUserId` and `_metadata` are copied from the link that produced the verification. If the link carried no `externalUserId`, the field arrives as an empty string; with no metadata, `_metadata` is `{}`.

### Geolocation on terminal events

`verification.successful` and `verification.failed` also carry `data.geolocation`, when a location was captured. Intermediate events never do — at `verification.started` the capture has not happened yet.

```json theme={null}
{
  "type": "verification.successful",
  "data": {
    "_verificationId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "_externalUserId": "customer-12345",
    "_metadata": {},
    "geolocation": {
      "ip": "200.45.12.9",
      "country": "AR",
      "city": "Buenos Aires",
      "subdivisions": "Ciudad Autónoma de Buenos Aires",
      "latitude": -34.6037,
      "longitude": -58.3816,
      "deviceLatitude": -34.6041,
      "deviceLongitude": -58.3822,
      "accuracy": 18,
      "accuracyRadius": 5,
      "source": "gps",
      "tier": "precise",
      "capturedAt": "2026-08-30T14:07:52.000Z"
    }
  }
}
```

Absent keys are dropped rather than sent as `null`, so treat every field as optional.

<Warning>
  The webhook geolocation is a subset. `permission` and `sample` are stripped, and the anti-fraud fields `policy`, `ipDistanceKm` and `serverIp` are **never** delivered by webhook — exactly as they are absent from the public endpoints. If your risk logic needs them, fetch the verification with a token after receiving the event.
</Warning>

## Verify the signature

VU signs the payload with **HMAC-SHA256 over the exact request body**, keyed with your account `webhookSecret`, and sends the hex digest in the **`X-Signature-SHA256`** header.

Verify against the raw body. Parsing and re-serializing the JSON first will change the bytes and break the comparison.

```javascript theme={null}
const crypto = require('crypto');
const express = require('express');
const app = express();

app.use(express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.VU_WEBHOOK_SECRET;

function verifySignature(rawBody, signature) {
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');
  const received = Buffer.from(signature, 'hex');
  const digest = Buffer.from(expected, 'hex');
  return received.length === digest.length && crypto.timingSafeEqual(received, digest);
}

app.post('/webhooks/vu', (req, res) => {
  const signature = req.headers['x-signature-sha256'];
  if (!signature || !verifySignature(req.body, signature)) {
    return res.status(401).send('Invalid signature');
  }

  // Answer first, work later.
  res.status(200).send('OK');

  const event = JSON.parse(req.body);
  enqueue(event.type, event.data._verificationId, event.data._externalUserId);
});

app.listen(3000);
```

## Delivery and retries

<Warning>
  Retries are deliberately short. VU makes **up to 3 attempts, 100 ms apart**, and stops as soon as your endpoint answers `200`, `201`, `202` or `204`. Anything else — a `500`, a timeout, a redirect — burns an attempt. After the third, the event is dropped and never redelivered.
</Warning>

That budget is far too small to absorb a slow handler, so:

* **Answer immediately**, then process asynchronously. Acknowledge inside a few hundred milliseconds and push the work to a queue.
* **Be idempotent.** The same event can arrive more than once, and `verification.started` is re-emitted on the adaptive branch under some retry paths.
* **Do not treat webhooks as the only source of truth.** Reconcile with `GET /verifications/{id}/status?linkId={linkId}` for anything that matters commercially — a verification whose webhook was lost still has a correct status on the API.
* **Read the result before it expires.** Past the verification's `expiresAt`, `GET /verifications/{id}` answers `410`. See [Plans](/en/identity-link/plans) for how that window is calculated.

## Manage endpoints

| Action | Endpoint | Returns |
| - | - | - |
| List all endpoints | `GET /webhook-endpoints` | Array of endpoints |
| Get one endpoint | `GET /webhook-endpoints/{id}` | One endpoint |
| Update endpoint | `PATCH /webhook-endpoints/{id}` | The updated endpoint |
| Delete endpoint | `DELETE /webhook-endpoints/{id}` | `{ "_id": "...", "deleted": true }` |

`PATCH` accepts `url` and `description`. Every route requires the same Bearer token as the rest of the API.

<Note>
  The `/webhook-endpoints` routes live on the same base URL as `POST /links`: `https://api.identity.vu-one.com/api/v1`. There is no separate host. If they answer `404` with `given resource is not implemented`, the route did not match: check the base URL, path, and method on the [Errors](/en/identity-link/errors#404-not-found) page.
</Note>

## Next steps

* Read extracted verification data in the [KYC Results](/en/identity-link/kyc) guide.
* Understand how liveness events map to webhook types in the [Liveness](/en/identity-link/liveness) guide.
* Review error codes if webhook registration fails on the [Errors](/en/identity-link/errors) page.


## Related topics

- [Quickstart: your first identity verification](/en/quickstart.md)
- [Create and Manage Verification Links](/en/identity-link/links.md)
- [Liveness Detection and Facial Biometrics in Identity Link](/en/identity-link/liveness.md)
- [Identity Link API Changelog](/en/identity-link/changelog.md)
- [Read KYC Results from Identity Verifications](/en/identity-link/kyc.md)


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