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

# Webhooks

> Receive KYC and Web inquiry events with Standard Webhooks signing.

InkLink can **POST** JSON event payloads to HTTPS URLs you control when verification results change. Delivery follows the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) conventions for headers and signing. **Before acting on a payload**, verify the signature using the **exact** raw request body and reject requests whose signing timestamp is outside a reasonable window to limit replay risk. **Expect duplicates**: the same logical event may arrive more than once (retries, slow responses, or concurrent workers), so use `webhook-id` and make handlers **idempotent**. Return **2xx** quickly and move heavy work to a background queue. InkLink’s HTTP timeouts, fixed retry schedule, and API permission rules are documented below.

## What you receive

Notifications are **thin** payloads: they include the inquiry and subject identifiers so your server can load full results via the existing KYC or Web inquiry APIs when needed.

## Event types

Each `type` uses the form `kyc.result.<status>` or `web.result.<status>`.

| `type`                     | When it fires                          |
| -------------------------- | -------------------------------------- |
| `kyc.result.pending`       | KYC inquiry is waiting to be completed |
| `kyc.result.manual_review` | Outcome needs manual review            |
| `kyc.result.approved`      | KYC approved                           |
| `kyc.result.rejected`      | KYC rejected                           |
| `kyc.result.failed`        | KYC failed (technical or policy)       |
| `web.result.pending`       | Web inquiry pending                    |
| `web.result.manual_review` | Outcome needs manual review            |
| `web.result.approved`      | Web inquiry approved                   |
| `web.result.rejected`      | Web inquiry rejected                   |
| `web.result.failed`        | Web inquiry failed                     |

## Example payload

```json theme={"dark"}
{
  "type": "web.result.approved",
  "timestamp": "2025-06-11T14:30:00.000Z",
  "data": {
    "inquiry_id": "web_iq_xxx",
    "subject_id": "user_123"
  }
}
```

## Inbound request shape

When an event matches one of your endpoint’s subscribed `event_types`, InkLink issues an HTTP **POST** to your URL with:

| Header              | Value                                                                     |
| ------------------- | ------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                        |
| `User-Agent`        | `InkLink-Webhooks/1.0`                                                    |
| `webhook-id`        | Stable id for this **event** (prefix `wh_evt_…`). Use for idempotency.    |
| `webhook-timestamp` | Unix time (**seconds**) when the request was signed, as a decimal string. |
| `webhook-signature` | Standard Webhooks form: `v1,<base64(HMAC-SHA256)>`                        |

The body is the **raw JSON** string of the payload (same bytes you must use when verifying).

Redirects are **not** followed (`fetch` uses `redirect: manual`). Treat **any** non-**2xx** response, including **3xx**, as a failed delivery.

## Verifying signatures

Keep your endpoint’s signing **`secret`** (prefix `whsec_`) on the server only. It is returned **once** when you [create](/api-reference/endpoint/webhook-endpoints-create) an endpoint or [rotate](/api-reference/endpoint/webhook-endpoint-rotate-secret) the secret.

### Using a Standard Webhooks library

The [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) ecosystem includes maintained [**reference implementations**](https://github.com/standard-webhooks/standard-webhooks/tree/main#reference-implementations) for many languages (Python, JavaScript/TypeScript, Go, Rust, and others). Prefer one of these: you pass the **raw** request body and the webhook headers, and the library verifies the signature (and usually enforces a timestamp tolerance).

For **Node.js**, install [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) from npm ([library source](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/javascript)). The `Webhook` constructor takes the **base64 key** only–use the part of your InkLink secret **after** the `whsec_` prefix as `base64_secret`. `webhook_payload` must be the **raw** body string (before JSON parsing); `webhook_headers` must include `webhook-id`, `webhook-timestamp`, and `webhook-signature`.

```javascript theme={"dark"}
import { Webhook } from "standardwebhooks";

const wh = new Webhook(base64_secret);
wh.verify(webhook_payload, webhook_headers);
```

### Manual verification (Node.js example)

Use this section if you are **not** using a library and need the exact signing procedure.

**Signing string** (must use the **exact** raw request body bytes):

```text theme={"dark"}
{webhook_id}.{webhook_timestamp}.{payload}
```

* **`webhook_id`** – value of the `webhook-id` header
* **`webhook_timestamp`** – value of the `webhook-timestamp` header (string)
* **`payload`** – raw JSON body as a string

Compute **HMAC-SHA256** using the decoded secret key (base64 portion after `whsec_`), **Base64**-encode the digest, and compare to the `v1,…` entry in `webhook-signature` using a constant-time comparison.

```javascript theme={"dark"}
const crypto = require("crypto");

function verifyInkLinkWebhook(rawBody, headers, secret) {
  const webhookId = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];

  if (!webhookId || !timestamp || !signatureHeader || !secret?.startsWith("whsec_")) {
    return false;
  }

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const signedContent = `${webhookId}.${timestamp}.${rawBody}`;
  const expected = crypto.createHmac("sha256", key).update(signedContent).digest("base64");

  const entries = signatureHeader.split(/\s+/);
  for (const entry of entries) {
    const [version, sig] = entry.split(",", 2);
    if (version !== "v1" || !sig) continue;
    try {
      const a = Buffer.from(sig, "base64");
      const b = Buffer.from(expected, "base64");
      if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
    } catch {
      continue;
    }
  }
  return false;
}
```

Use the **raw** body (before JSON parsing) so the bytes match what InkLink signed. Rejecting timestamps far in the past or future limits replay risk; the [Standard Webhooks spec](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) recommends a tolerance window (libraries typically apply this for you).

## Reliability and delivery attempts

Respond with **2xx** quickly and offload heavy work to a background queue. InkLink records whether each HTTP attempt succeeded (**status 200–299**).

**Retries** are **not** “wait after failure then backoff.” Each delivery uses **five fixed time slots** measured from that delivery’s **creation** time (wall-clock):

| Attempt | Earliest time after delivery is created |
| ------- | --------------------------------------- |
| 1       | 0 s                                     |
| 2       | 30 s                                    |
| 3       | 90 s                                    |
| 4       | 270 s (4 min 30 s)                      |
| 5       | 720 s (12 min)                          |

Before each attempt the worker waits until that **absolute** time (or runs immediately if the clock is already past the slot). Each HTTP call uses an **8 second** request timeout.

If all five attempts fail (non-**2xx**, timeout, DNS, TLS, etc.), the delivery is marked failed and is not retried further by this scheduler.

**Idempotency:** the same logical event reuses the same `webhook-id`. Design handlers so duplicate deliveries (for example after a slow **2xx** or overlapping workers) are safe.

## Managing endpoints (API)

Use your organization’s API key on every call ([Authentication](/authentication)). Programmatic routes:

* [List webhook endpoints](/api-reference/endpoint/webhook-endpoints-list) – `GET /webhook/endpoints`
* [Create webhook endpoint](/api-reference/endpoint/webhook-endpoints-create) – `POST /webhook/endpoints` (returns `secret` once)
* [Get webhook endpoint](/api-reference/endpoint/webhook-endpoint-get) – `GET /webhook/endpoints/{id}`
* [Update webhook endpoint](/api-reference/endpoint/webhook-endpoint-update) – `PATCH /webhook/endpoints/{id}`
* [Delete webhook endpoint](/api-reference/endpoint/webhook-endpoint-delete) – `DELETE /webhook/endpoints/{id}`
* [Rotate webhook signing secret](/api-reference/endpoint/webhook-endpoint-rotate-secret) – `POST /webhook/endpoints/{id}/rotate-secret`

**Roles:** creating, updating, deleting, and rotating secrets requires an **organization owner** or **admin**. If the key’s user lacks permission, the API may respond with **500** `INTERNAL_ERROR` and a clear message (see [Error handling](/error-handling)). Listing and fetching endpoints is available to authenticated organization members.

## Local and staging testing

Your URL must be reachable from InkLink’s servers. For local development, use a tunnel (for example **ngrok**, **Cloudflare Tunnel**, or a cloud dev environment) that terminates **HTTPS** with a public hostname.
