# Guide to Incode Webhooks: Features and Best Practices

Incode webhooks tell your application where a user is in an identity verification: an ID has been validated, the onboarding session finished, a session failed or needs manual review, a watchlist result changed. If you run onboarding or KYC flows on Incode, webhooks let your back end act on results as soon as they're ready instead of polling for session status.

This guide covers the webhook types Incode offers, their payloads, how to authenticate requests that Incode doesn't sign, the response Incode needs to stop retrying, and the practices that keep a receiver reliable.

## What are Incode webhooks?

Incode webhooks are one-way notifications from the Incode platform to your application. You configure each webhook type in the Incode Dashboard with a single URL. If several of your services need the same notification, Incode says to receive it once and "broadcast it internally".

Every notification is a JSON `POST`. Requests are not signed. There are two ways to authenticate them:

* **Custom headers.** You define header names and static values in the dashboard's general webhook settings, and Incode sends them with every webhook. Incode notes that "all headers are sent for all webhooks. Dynamic values are not supported."
* **OIDC.** Incode obtains an OAuth 2.0 access token from your authorization server and sends `Authorization: Bearer <ACCESS_TOKEN>`. This needs an authorization server on your side.

Incode is strict about the response. Anything other than a `200` with an `application/json` content type, or a `204`, counts as a failure and is retried.

## Incode webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard: **Configuration > Webhooks** |
| URLs | One URL per webhook type |
| Method | `POST`, JSON body |
| Authentication | Static custom headers (all webhooks), or OIDC bearer tokens |
| Signature | None |
| Success response | `200` with `Content-Type: application/json`, or `204` |
| Retries | On a timeout or any other response: exponential from 30 seconds, multiplier 2.5, at most 5 attempts, about 32 minutes in total |
| Source IPs | Published per environment: US, EU, Canada and demo |

## Webhook types and events

| Webhook | Fires when |
| --- | --- |
| Onboarding status | The session's onboarding status changes, after each module |
| Session Started, Progress, Failed, Succeeded, Pending Review | The matching session event happens in any Flow or Workflow session |
| Identity Enrolled | An Identity is created for the first time |
| Videoselfie upload | The video selfie recording becomes available |
| Global watchlists | A global watchlist result is updated |
| Face Authentication | A face authentication succeeds or fails |
| Work history, Proof of payment | Processing finishes (Mexico only) |
| KYB Results | A KYB run's final result is ready |

The overview page lists four session webhooks, while the session webhooks page documents six, adding Session Progress and Identity Enrolled.

The onboarding status webhook fires once per module, so it can be chatty. Its body is small:

```json
{
  "clientId": "MyClientId001",
  "flowId": "66969519eb1d789a96347ca7",
  "interviewId": "664283755e0f8e1b87fcc2ce",
  "externalCustomerId": "MyCustomerID#0001",
  "onboardingStatus": "ONBOARDING_FINISHED"
}

```

`onboardingStatus` moves through values such as `ID_VALIDATION_FINISHED`, `GOVERNMENT_VALIDATION_FINISHED`, `FACE_VALIDATION_FINISHED`, `POST_PROCESSING_FINISHED` and `ONBOARDING_FINISHED`, then `MANUAL_REVIEW_APPROVED` or `MANUAL_REVIEW_REJECTED` for sessions sent to review. `EXPIRED` and `DELETED` can also arrive, and a `DELETED` notification carries an `interviewIds` array instead of `interviewId`. Incode recommends waiting for `ONBOARDING_FINISHED` before fetching session data. `externalCustomerId` is present only if you passed one when starting the session.

Session webhooks are built for single outcomes. They carry `event_type` (`SESSION_STARTED`, `SESSION_PROGRESS`, `SESSION_FAILED`, `SESSION_SUCCEEDED` or `SESSION_PENDING_REVIEW`), an ISO 8601 `timestamp`, `interviewId`, `clientId` and `configurationId`. Finished sessions add a `devices` array, `identityId` where one applies, and on `SESSION_FAILED`, a sorted `failureReasons` array such as `["ID_VALIDATION", "LIVENESS"]`. Identity Enrolled names itself in `eventType`, in camelCase, rather than `event_type`.

## Setting up Incode webhooks

Pick a header name and a long random value first, for example `X-Incode-Webhook-Secret` with the output of `openssl rand -hex 32`, and store the value server-side.

1. In the Incode Dashboard, click **Configuration**, then the **Webhooks** tab.
2. Under the general settings, click the plus sign and enter your **Custom Header Name** and **Custom Header Value**. Click **Update Settings**.
3. Scroll down and click **Generate New**. Enter a **Name**, choose the **Type** and enter the **URL**.
4. Repeat for each webhook type you need.

**With Hookdeck.** Incode is a supported source in Hookdeck. Create a source with the Incode type, enter the custom header's name as the **API Key Header** and its value as the **API Key**, and use the Hookdeck source URL as each webhook's URL. Hookdeck's setup note says to add the custom header in **Dashboard > Configuration > Webhooks** and enter it on the source, and warns that Incode retries unless it gets a `200` with a JSON content type, or a `204`. Hookdeck's default response is a `200` with a JSON body, which satisfies Incode. If you set a custom response on the source, keep it JSON. The source accepts `POST` only, which is the method Incode uses. A request without the right header still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service. Hookdeck can't check OIDC bearer tokens, because they change, so use a custom header with this source.

## Securing Incode webhooks

Compare the custom header with the stored value in constant time, and answer with JSON so Incode doesn't retry. Express's `res.sendStatus(200)` sends a plain-text body, which Incode counts as a failure.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyIncodeRequest(headers) {
  const secret = process.env.INCODE_WEBHOOK_SECRET;
  if (!secret) return false; // fail closed when unconfigured
  const name = (process.env.INCODE_WEBHOOK_HEADER || 'X-Incode-Webhook-Secret').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, secret);
}

const app = express();

app.post('/webhooks/incode', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyIncodeRequest(req.headers)) {
    return res.status(401).json({ error: 'unauthorized' });
  }

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.status(400).json({ error: 'invalid JSON' });
  }
  // body.event_type, body.eventType or body.onboardingStatus, depending on the webhook.
  res.status(200).json({ success: true }); // a 200 must be JSON, or Incode retries
});

```

The same check in Python:

```python
import hmac
import os

def verify_incode_request(headers) -> bool:
    secret = os.environ.get("INCODE_WEBHOOK_SECRET")
    if not secret:
        return False
    name = os.environ.get("INCODE_WEBHOOK_HEADER", "X-Incode-Webhook-Secret")
    received = headers.get(name)  # case-insensitive in Starlette and Flask
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

# Respond with JSONResponse({"success": True}) or a 204, never a plain-text 200.

```

A static header proves the sender knows the value, but it doesn't prove the body is unchanged, and it gives no replay protection: nothing is signed. Serve the endpoint over HTTPS, keep the value long and random, and keep it out of logs. Because the headers are shared by every webhook type, one value protects them all, so rotate it everywhere at once. Incode also publishes source IPs for each environment, which you can allowlist alongside the header.

> **Make Incode webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Incode with the JSON `200` it expects, checks your custom header, stores every onboarding and session event, and keeps retrying delivery to your service long after Incode's 32-minute window.

## Incode webhook limitations and pain points

### A plain `200` counts as a failure

**The Problem:** Your endpoint processes each notification, yet Incode keeps resending it.

**Why It Happens:** Incode treats only a `200` with an `application/json` content type, or a `204`, as success. A `200` with a text body, which many frameworks send by default, triggers the retry policy.

**Workarounds:**

* Return `204`, or a `200` with a JSON body such as `{ "success": true }`.
* Make handlers idempotent, so a retry caused this way does no harm.

**How Hookdeck Can Help:** Hookdeck's default response is a JSON `200`, so Incode stops retrying as soon as Hookdeck has the event, whatever your own service returns.

### A short retry window

**The Problem:** An outage of more than about half an hour loses notifications.

**Why It Happens:** Incode retries with an exponential backoff from 30 seconds, a multiplier of 2.5 and a maximum of 5 attempts, which Incode puts at about 32 minutes in total.

**Workarounds:**

* Return quickly and process asynchronously.
* After an outage, fetch session status from the Incode API for sessions started in that window.

**How Hookdeck Can Help:** Hookdeck accepts and stores each notification, then delivers it to your service with [automatic retries](/docs/retries) on a schedule you control. [Issues](/docs/issues) alert you when deliveries fail, and stored events can be replayed.

### One header for every webhook

**The Problem:** You can't give each webhook type its own secret.

**Why It Happens:** Custom headers are a general setting: "all headers are sent for all webhooks", and their values must be static.

**Workarounds:**

* Use one long random value and rotate it carefully across every receiver.
* Use a different URL path per webhook type to keep handlers apart.

**How Hookdeck Can Help:** One Hookdeck source can receive every Incode webhook type, check the header once, and route each type to the right service with [filters](/docs/filters).

### Three ways to name the event

**The Problem:** One parser can't read every Incode payload the same way.

**Why It Happens:** Session webhooks use `event_type`, Identity Enrolled uses `eventType`, the onboarding status webhook uses `onboardingStatus`, and other types name nothing at all.

**Workarounds:**

* Give each webhook type its own URL, and dispatch on the URL first.
* Normalise into one internal event shape at the edge of your system.

**How Hookdeck Can Help:** Hookdeck [transformations](/docs/transformations) can add a common event name to each payload before it reaches your service.

## Best practices

### Choose session webhooks for outcomes

If you only need to know how a session ended, subscribe to Session Succeeded, Failed and Pending Review rather than the onboarding status webhook, which fires after every module.

### Fetch data after `ONBOARDING_FINISHED`

Earlier statuses let you fetch partial results, but Incode recommends waiting for `ONBOARDING_FINISHED` so scores and business rules are final.

### Read `failureReasons`, not `failureReason`

Incode removed the singular `failureReason` field from `SESSION_FAILED` and replaced it with the `failureReasons` array. Update older handlers.

### Acknowledge fast and process asynchronously

Check the header, store the notification, answer with JSON or `204`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Incode sends unsigned JSON `POST` requests for each webhook type you configure, with any static custom headers you define. Compare the header in constant time over HTTPS, answer with a JSON `200` or a `204` so Incode stops retrying, dispatch on each webhook type's own field, and plan for a retry window of about 32 minutes.

[Hookdeck Event Gateway](https://hookdeck.com) gives Incode the JSON response it expects, checks the header, and holds every verification event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Incode webhooks reliably in minutes.