# V-PIN Webhook

## Status

{% hint style="warning" %}
**Early Access:** This is an early version for select partners. Interfaces and payloads may change, and we will work with partners on updates. Contact support to request access.
{% endhint %}

## Purpose

Notify customers when Veratad determines that multiple V-PINs refer to the same individual and consolidates them into a single canonical V-PIN, when an issued V-PIN must be split because it was assigned to multiple distinct people, or when a V-PIN is retired after being linked to fraudulent activity.

***

## High-Level Overview

When a client is returned a V-PIN in any request across all Veratad services, that V-PIN is automatically added to a monitor for that client. If future intelligence indicates that multiple V-PINs actually represent the same person, Veratad will merge them and publish a `vpin.merged` webhook event to subscribed endpoints. If we later discover that a single V-PIN was used for more than one individual, Veratad will split it into distinct V-PINs and emit a `vpin.split` event that lists the replacements. If a V-PIN is determined to be synthetic or tied to a fake identity, Veratad will retire it and emit a `vpin.retired` event.

Customers should treat the canonical V-PIN as the ongoing identifier and migrate all references from the superseded V-PIN(s) to the canonical V-PIN. Retired V-PINs should be purged from systems and not used in future transactions.

***

## Event Model

### Event Types

* `vpin.merged` — Sent when one or more V-PINs are merged into a canonical V-PIN.
* `vpin.split` — Sent when a V-PIN is split into multiple replacement V-PINs.
* `vpin.retired` — Sent when a V-PIN is permanently retired due to confirmed or suspected fraud.
* `vpin.merge.reverted` — Sent if a prior merge is undone.
* `vpin.retire.reverted` — Sent if a previously retired V-PIN is reinstated.

`vpin.merged` is required for monitoring. Other events, including `vpin.split` and `vpin.retired`, are opt‑in.

### Event Envelope

All webhook deliveries share a standard envelope for parsing and idempotency.

```json
{
  "id": "evt_01J6X9VQ8E2Q3RZ2KQYH3F7W2B",
  "type": "vpin.merged",
  "version": "2025-09-10",
  "created_at": "2025-09-10T14:22:31.840Z",
  "data": {},
  "trace": {
    "correlation_id": "cor_3b6f6d7e-4a6d-4f7a-b60a-2f8e3fef1c9a",
    "source": "veratad.vpin.monitoring"
  }
}
```

* `id` — Unique event id used for idempotency.
* `version` — Semantic version/date of payload contract.
* `trace.correlation_id` — Stable id for cross-system troubleshooting.

***

## `vpin.merged` — Payload Contract

```json
{
  "canonical_vpin": "15ebd7a0-2b4e-4d4b-b2a5-54b5a24becce",
  "superseded": [
    {
      "vpin": "a1a1d7a0-1111-4d4b-b2a5-54b5a24be001",
      "first_seen_at": "2024-06-02T11:05:09Z",
      "last_used_at": "2025-08-29T17:21:04Z"
    },
    {
      "vpin": "b2b2d7a0-2222-4d4b-b2a5-54b5a24be002",
      "first_seen_at": "2024-10-18T09:44:20Z",
      "last_used_at": "2025-09-01T12:10:33Z"
    }
  ],
  "effective_at": "2025-09-10T14:22:31Z",
  "reason": {
    "code": "NEW_DATA_AVAILABLE",
    "summary": "Trusted external source linked these identifiers",
    "signals": [
      { "name": "external_data_source", "value": "verified_partner" },
      { "name": "human_review", "value": true },
      { "name": "ai_agent_review", "value": true }
    ]
  },
  "actions": {
    "redirect_window_days": 90,
    "resolution_endpoint": "/v1/vpin/resolve/{vpin}",
    "replay_from": "2024-01-01T00:00:00Z"
  }
}
```

* `canonical_vpin` — V-PIN that survives after the merge; use this going forward.
* `superseded` — Array of V-PINs that should no longer be used.
* `effective_at` — Timestamp from which the merge is authoritative.
* `reason` — Machine and/or human justification. Common codes include `NEW_DATA_AVAILABLE`, `HUMAN_REVIEW`, and `AI_AGENT_REVIEW`.
* `actions.redirect_window_days` — Period during which Veratad will auto-resolve superseded V-PINs.
* `actions.resolution_endpoint` — Companion API to resolve any historical V-PIN to the canonical V-PIN.
* `actions.replay_from` — Earliest recommended timestamp to re-key historical records, if needed.

***

## `vpin.split` — Payload Contract

```json
{
  "source_vpin": "15ebd7a0-2b4e-4d4b-b2a5-54b5a24becce",
  "replacements": [
    {
      "vpin": "c3c3d7a0-3333-4d4b-b2a5-54b5a24be003",
      "first_seen_at": "2025-09-12T18:00:00Z",
      "links": [
        { "name": "first_name", "value": "Jo***" },
        { "name": "last_name", "value": "Sm***" },
        { "name": "date_of_birth", "value": "1988-04-**" },
        { "name": "verification_confirmation_number", "value": "VRFD-20240915-****" }
      ]
    },
    {
      "vpin": "d4d4d7a0-4444-4d4b-b2a5-54b5a24be004",
      "first_seen_at": "2025-09-12T18:05:32Z",
      "links": [
        { "name": "first_name", "value": "Ma***" },
        { "name": "last_name", "value": "Le***" },
        { "name": "date_of_birth", "value": "1991-11-**" },
        { "name": "verification_confirmation_number", "value": "VPIN-20240801-****" }
      ]
    }
  ],
  "effective_at": "2025-09-12T18:10:00Z",
  "reason": {
    "code": "COLLISION_DETECTED",
    "summary": "Identity collision review determined the original V-PIN represented multiple people",
    "signals": [
      { "name": "human_review", "value": true }
    ]
  },
  "actions": {
    "resolution_endpoint": "/v1/vpin/resolve/{vpin}",
    "superseded_vpin_status": "retired"
  }
}
```

* `source_vpin` — The V-PIN that previously represented more than one person.
* `replacements` — Array of new V-PINs to use going forward. Metadata is masked to protect PII and help route each record to the correct subject.
* `effective_at` — Timestamp from which the split mapping is authoritative.
* `reason` — Machine and/or human justification for the split. Common codes include `COLLISION_DETECTED`, `HUMAN_REVIEW`, and `AI_AGENT_REVIEW`.
* `actions.resolution_endpoint` — Use to resolve existing references to the correct replacement V-PIN.
* `actions.superseded_vpin_status` — Expected disposition of the original V-PIN (for example, `retired`).

***

## `vpin.retired` — Payload Contract

```json
{
  "vpin": "a1a1d7a0-1111-4d4b-b2a5-54b5a24be001",
  "retired_at": "2025-09-15T10:00:00Z",
  "reason": {
    "code": "HUMAN_REVIEW",
    "summary": "Manual review of external data flagged this V-PIN"
  }
}
```

* `vpin` — V-PIN that has been retired and should no longer be used.
* `retired_at` — Timestamp from which the retirement is authoritative.
* `reason` — Machine and/or human justification for retirement. Examples: `HUMAN_REVIEW`, `AI_AGENT_REVIEW`, `EXTERNAL_DATA_CONFLICT`.

***

## Resolution API

Resolve any V-PIN to its current canonical V-PIN or understand how to handle a split.

<mark style="color:green;">`GET`</mark> `/v1/vpin/resolve/{vpin}`

**Response 200**

```json
{
  "input": "b2b2d7a0-2222-4d4b-b2a5-54b5a24be002",
  "canonical_vpin": "15ebd7a0-2b4e-4d4b-b2a5-54b5a24becce",
  "superseded": true,
  "effective_at": "2025-09-10T14:22:31Z"
}
```

**Response 404**

```json
{ "error": "VPIN_NOT_FOUND" }
```

**Response 409** (V-PIN was split)

```json
{
  "input": "15ebd7a0-2b4e-4d4b-b2a5-54b5a24becce",
  "split": true,
  "replacements": [
    {
      "vpin": "c3c3d7a0-3333-4d4b-b2a5-54b5a24be003",
      "effective_at": "2025-09-12T18:10:00Z"
    },
    {
      "vpin": "d4d4d7a0-4444-4d4b-b2a5-54b5a24be004",
      "effective_at": "2025-09-12T18:10:00Z"
    }
  ]
}
```

***

## Webhook Delivery Mechanics

### Registration

Include your API token in the `Authorization` header when registering.

<mark style="color:green;">`POST`</mark> `/v1/vpin/webhooks/subscriptions`

**Headers**

* `Authorization: Bearer {api_token}`

**Request**

```json
{
  "url": "https://api.yourcompany.com/veratad/webhooks",
  "event_types": ["vpin.merged", "vpin.retired"],
  "secret": "(one-time-generated secret)",
  "enabled": true
}
```

**Response 201**

```json
{
  "id": "whsub_01J6XA2C3Y7D4",
  "url": "https://api.yourcompany.com/veratad/webhooks",
  "event_types": ["vpin.merged", "vpin.retired"],
  "status": "active",
  "created_at": "2025-09-10T13:11:21Z"
}
```

### Security & Signing

All webhook requests are signed with an HMAC SHA‑256 using your subscription secret.

**Headers**

* `X-Veratad-Signature` — Hex digest of HMAC SHA‑256 over body, using the subscription secret.
* `X-Veratad-Timestamp` — Milliseconds since epoch when the signature was computed.
* `X-Veratad-Event-Id` — Same as envelope id for idempotency.

**Signature Base String**

```
{timestamp}.{raw_request_body}
```

Compute `HMAC_SHA256(secret, base_string)` and compare (constant‑time) to `X-Veratad-Signature`.

**Replay Protection**

Reject requests where `abs(now - X-Veratad-Timestamp) > 5 minutes`.

#### Example: Verifying Signature

{% tabs %}
{% tab title="TypeScript" %}

```typescript
import crypto from 'crypto';

export function verifyWebhook({
  rawBody,
  timestamp,
  signature,
  secret,
}: {
  rawBody: string;
  timestamp: string;
  signature: string;
  secret: string;
}): void {
  const base = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(base)
    .digest('hex');

  const valid = crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );

  const age = Math.abs(Date.now() - Number(timestamp));
  if (!valid || age > 5 * 60 * 1000) {
    throw new Error('Invalid signature');
  }
}
```

{% endtab %}

{% tab title="Python" %}

```python
import hashlib
import hmac
import time

def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> None:
    base = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected = hmac.new(secret.encode(), base.encode(), hashlib.sha256).hexdigest()

    valid = hmac.compare_digest(signature, expected)

    age = abs(int(time.time() * 1000) - int(timestamp))
    if not valid or age > 5 * 60 * 1000:
        raise ValueError('Invalid signature')
```

{% endtab %}
{% endtabs %}

### Retries & Ordering

* Retries: Up to five attempts with exponential backoff (1m, 5m, 15m, 60m, 120m).
* Idempotency: Use `X-Veratad-Event-Id` to dedupe. We may deliver an event more than once.
* Ordering: Best-effort in-order per subscription; do not rely on strict global ordering.

### Response Expectations

Return 2xx to acknowledge receipt. Any non‑2xx will be treated as a failure and retried.

***

## Consumer Responsibilities

1. Verify signature on every request.
2. Deduplicate using the event id.
3. For `vpin.merged` events, migrate references from `superseded[].vpin` to `canonical_vpin`.
4. For `vpin.split` events, evaluate the `replacements[]` payload and re-key impacted records to the correct new V-PIN; archive or retire any lingering references to `source_vpin`.
5. Update caches and any identity graphs keyed by V-PIN.
6. Optionally re-key history from `actions.replay_from` if your use case requires.

***

## Change Management & Versioning

* The envelope version and the `vpin` event payloads (e.g., `vpin.merged`, `vpin.split`, `vpin.retired`) may evolve. Backwards-compatible changes include new optional fields.
* Breaking changes will increment the **MAJOR** version (e.g., `2026-01-01`) with 90 days’ notice.

***

## Examples

### Merge of Two V-PINs

**Request (from Veratad to your webhook)**

```http
POST /veratad/webhooks HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
X-Veratad-Event-Id: evt_01J6X9VQ8E2Q3RZ2KQYH3F7W2B
X-Veratad-Timestamp: 1757517751840
X-Veratad-Signature: 9e3b0c...d2

{
  "id": "evt_01J6X9VQ8E2Q3RZ2KQYH3F7W2B",
  "type": "vpin.merged",
  "version": "2025-09-10",
  "created_at": "2025-09-10T14:22:31.840Z",
  "data": {
    "canonical_vpin": "15ebd7a0-2b4e-4d4b-b2a5-54b5a24becce",
    "superseded": [
      { "vpin": "a1a1d7a0-1111-4d4b-b2a5-54b5a24be001" },
      { "vpin": "b2b2d7a0-2222-4d4b-b2a5-54b5a24be002" }
    ],
    "effective_at": "2025-09-10T14:22:31Z",
    "reason": {
      "code": "NEW_DATA_AVAILABLE",
      "summary": "Trusted external data indicates these V-PINs refer to the same person"
    },
    "actions": {
      "redirect_window_days": 90,
      "resolution_endpoint": "/v1/vpin/resolve/{vpin}"
    }
  },
  "trace": {
    "correlation_id": "cor_3b6f6d7e-4a6d-4f7a-b60a-2f8e3fef1c9a",
    "source": "veratad.vpin.monitoring"
  }
}
```

**Expected Handler Response**

```http
HTTP/1.1 200 OK
```

### Split of a Shared V-PIN

**Request (from Veratad to your webhook)**

```http
POST /veratad/webhooks HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
X-Veratad-Event-Id: evt_01J6Y3M4N5P6Q7R8S9T0U1V2W3
X-Veratad-Timestamp: 1757691000000
X-Veratad-Signature: 8b7c1d...aa

{
  "id": "evt_01J6Y3M4N5P6Q7R8S9T0U1V2W3",
  "type": "vpin.split",
  "version": "2025-09-10",
  "created_at": "2025-09-12T18:10:00.000Z",
  "data": {
    "source_vpin": "15ebd7a0-2b4e-4d4b-b2a5-54b5a24becce",
    "replacements": [
      { "vpin": "c3c3d7a0-3333-4d4b-b2a5-54b5a24be003" },
      { "vpin": "d4d4d7a0-4444-4d4b-b2a5-54b5a24be004" }
    ],
    "effective_at": "2025-09-12T18:10:00Z",
    "reason": {
      "code": "COLLISION_DETECTED",
      "summary": "Shared V-PIN was reassigned to two unique identities"
    }
  },
  "trace": {
    "correlation_id": "cor_5c7a9b1d-2e3f-4a5b-8c9d-0e1f2a3b4c5d",
    "source": "veratad.vpin.monitoring"
  }
}
```

**Expected Handler Response**

```http
HTTP/1.1 200 OK
```

### Retirement of a V-PIN

**Request (from Veratad to your webhook)**

```http
POST /veratad/webhooks HTTP/1.1
Host: api.yourcompany.com
Content-Type: application/json
X-Veratad-Event-Id: evt_01J6X9VQ8E2Q3RZ2KQYH3F7W2B
X-Veratad-Timestamp: 1757517751840
X-Veratad-Signature: 9e3b0c...d2

{
  "id": "evt_01J6X9VQ8E2Q3RZ2KQYH3F7W2B",
  "type": "vpin.retired",
  "version": "2025-09-10",
  "created_at": "2025-09-15T10:00:00Z",
  "data": {
    "vpin": "a1a1d7a0-1111-4d4b-b2a5-54b5a24be001",
    "retired_at": "2025-09-15T10:00:00Z",
    "reason": {
        "code": "HUMAN_REVIEW",
        "summary": "Manual review of external data flagged this V-PIN"
    }
  },
  "trace": {
    "correlation_id": "cor_3b6f6d7e-4a6d-4f7a-b60a-2f8e3fef1c9a",
    "source": "veratad.vpin.monitoring"
  }
}
```

**Expected Handler Response**

```http
HTTP/1.1 200 OK
```

***

## Test & Sandbox

Use these endpoints to validate your integration.

* Send test event: <mark style="color:green;">`POST`</mark> `/v1/vpin/webhooks/subscriptions/{id}:test` with `{ "type": "vpin.merged" }`, `{ "type": "vpin.split" }`, or `{ "type": "vpin.retired" }` to receive a mock delivery.

```bash
curl -X POST https://production.response.com/v1/vpin/webhooks/subscriptions/{id}:test \
  -H "Authorization: Bearer {api_token}" \
  -H "Content-Type: application/json" \
  -d '{ "type": "vpin.split" }'
```

* Replay live event: <mark style="color:green;">`POST`</mark> `/v1/vpin/webhooks/events/{event_id}:replay` re-delivers a historical event to your endpoint (authorization required).

```bash
curl -X POST https://production.response.com/v1/vpin/webhooks/events/{event_id}:replay \
  -H "Authorization: Bearer {api_token}"
```

***

## Changelog

* `2025-09-10`: Early access release.
