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_. This needs an authorization server on your side.TOKEN>
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:
{
"clientId": "MyClientId001",
"flowId": "66969519eb1d789a96347ca7",
"interviewId": "664283755e0f8e1b87fcc2ce",
"externalCustomerId": "MyCustomerID#0001",
"onboardingStatus": "ONBOARDING_FINISHED"
}
onboardingStatus moves through values such as ID_VALIDATION_FINISHED, GOVERNMENT_, 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_. 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.
- In the Incode Dashboard, click Configuration, then the Webhooks tab.
- Under the general settings, click the plus sign and enter your Custom Header Name and Custom Header Value. Click Update Settings.
- Scroll down and click Generate New. Enter a Name, choose the Type and enter the URL.
- 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.
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:
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 answers Incode with the JSON
200it 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 a200with 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 on a schedule you control. 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.
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 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 and our guide to 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 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 for free and handle Incode webhooks reliably in minutes.