Agent skill
Incode Webhooks Skill
Receive and authenticate Incode identity verification webhooks. Use when setting up an Incode webhook endpoint, checking the custom header you add in Dashboard > Configuration > Webhooks, debugging 401s or endless retries, or handling events like SESSION_SUCCEEDED, SESSION_FAILED, IDENTITY_ENROLLED and ONBOARDING_FINISHED.
Install this skill
npx skills add hookdeck/webhook-skills --skill incode-webhooks
Incode sends a JSON POST when something happens in an identity verification session: a session starts, succeeds, fails or needs review, an identity is enrolled, or the onboarding status changes. Webhooks are not signed. You authenticate them with a custom header that you add in Dashboard > Configuration > Webhooks; Incode sends it on every webhook, and your endpoint compares it with the value you stored.
Add a custom header in Dashboard > Configuration > Webhooks and enter it here. Incode retries unless it gets a 200 with a JSON content type, or a 204, so keep any custom source response JSON.
(That is the setup note Hookdeck shows for its Incode source, type INCODE.)
When to Use This Skill
- How do I receive Incode webhooks?
- How do I authenticate Incode webhook requests? Does Incode sign them? (No: use a custom header)
- What response does Incode need to stop retrying?
- How do I handle
SESSION_SUCCEEDED,SESSION_FAILEDorIDENTITY_ENROLLEDevents? - Why does my Incode webhook endpoint return 401, or why does Incode keep retrying?
Verification (core)
const crypto = require('crypto');
// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
const ha = crypto.createHash('sha256').update(String(a)).digest();
const hb = crypto.createHash('sha256').update(String(b)).digest();
return crypto.timingSafeEqual(ha, hb);
}
function verifyIncodeRequest(headers, secret = process.env.INCODE_WEBHOOK_SECRET,
name = process.env.INCODE_WEBHOOK_HEADER || 'Authorization') {
if (!secret) return false; // never accept when unconfigured
const received = headers[name.toLowerCase()]; // Node lowercases header names
return typeof received === 'string' && received !== '' && safeEqual(received, secret); // WHOLE value, no prefix parsing
}
- The header name is whatever you typed as Custom Header Key, so read it from
INCODE_WEBHOOK_HEADER(defaultAuthorization) and look it up case-insensitively. - The expected value is the exact, whole Custom Header Value, including any
Bearerprefix you typed. Do not strip or parse a prefix. - Missing header or mismatch:
401. Python:hmac.compare_digeston.encode()d bytes, never onstr(it raises on non-ASCII). - Read the body raw (
express.raw({ type: '*/*' })) and authenticate before parsing it. - Security limits: a static shared credential authenticates the sender, not the body, and gives no replay protection (no signature, no timestamp). Use HTTPS and rotate the credential if it leaks.
- OIDC / OAuth is not covered. Incode can instead authorize webhooks with an OAuth 2.0 bearer token (
Authorization: Bearer <ACCESS_, issued by your authorization server). That token changes, so it can't be checked against a fixed value, and Hookdeck's Incode source does not support it. Use a custom header.TOKEN>
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Respond Correctly
Incode retries (5 attempts, exponential from 30s, about 32 minutes in total) on a timeout or any status other than 200 with an application/json content type, or 204. The examples answer 200 {"success": true} via res.json / NextResponse.json / JSONResponse. If you configure a custom response in Hookdeck for this source, keep it JSON.
Event Types
Each webhook kind carries its name in a different field. Read event_type, then eventType, then onboardingStatus.
| Field | Event | Sent When |
|---|---|---|
event_type | SESSION_STARTED | A Flow or Workflow session starts |
event_type | SESSION_PROGRESS | A session is resumed (a /start call after creation) |
event_type | SESSION_SUCCEEDED | A session succeeds |
event_type | SESSION_FAILED | A session fails (see failureReasons) |
event_type | SESSION_PENDING_REVIEW | A session needs manual review |
eventType | IDENTITY_ENROLLED | An Identity is created for the first time |
onboardingStatus | ID_VALIDATION_FINISHED, ID_, GOVERNMENT_, FACE_VALIDATION_FINISHED, POST_PROCESSING_FINISHED, POST_ | A module of an onboarding session completes |
onboardingStatus | ONBOARDING_FINISHED | All data is collected and the final status is set |
onboardingStatus | MANUAL_REVIEW_APPROVED, MANUAL_REVIEW_REJECTED | A reviewer decides a manual-review session |
onboardingStatus | EXPIRED | A session passes its expiry time |
onboardingStatus | DELETED | Session data is deleted (carries interviewIds, an array) |
Other webhooks (video selfie, watchlist, face authentication, KYB results and the Mexico-only ones) have their own payloads; see references/overview.md.
Environment Variables
INCODE_WEBHOOK_HEADER=Authorization # Custom Header Key you added in the dashboard
INCODE_WEBHOOK_SECRET=your-header-value # Custom Header Value, the exact whole value
Local Development
Incode can't reach localhost. Tunnel with the Hookdeck CLI (no account required):
npx hookdeck-cli listen 3000 incode --path /webhooks/incode
Use the printed URL as the webhook URL in Incode. Add the custom header in Incode and in the Hookdeck source (header name and exact value) so both ends agree.
Resources
- references/overview.md - Webhook kinds, events and payload shapes
- references/setup.md - Add the custom header and webhook URL in the dashboard
- references/verification.md - Header check details, gotchas, debugging 401s
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests