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_FAILED or IDENTITY_ENROLLED events?
  • 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 (default Authorization) and look it up case-insensitively.
  • The expected value is the exact, whole Custom Header Value, including any Bearer prefix you typed. Do not strip or parse a prefix.
  • Missing header or mismatch: 401. Python: hmac.compare_digest on .encode()d bytes, never on str (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_TOKEN>, 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.

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.

FieldEventSent When
event_typeSESSION_STARTEDA Flow or Workflow session starts
event_typeSESSION_PROGRESSA session is resumed (a /start call after creation)
event_typeSESSION_SUCCEEDEDA session succeeds
event_typeSESSION_FAILEDA session fails (see failureReasons)
event_typeSESSION_PENDING_REVIEWA session needs manual review
eventTypeIDENTITY_ENROLLEDAn Identity is created for the first time
onboardingStatusID_VALIDATION_FINISHED, ID_VALIDATION_FINISHED_SECOND_ID, GOVERNMENT_VALIDATION_FINISHED, FACE_VALIDATION_FINISHED, POST_PROCESSING_FINISHED, POST_PROCESSING_FINISHED_SECOND_IDA module of an onboarding session completes
onboardingStatusONBOARDING_FINISHEDAll data is collected and the final status is set
onboardingStatusMANUAL_REVIEW_APPROVED, MANUAL_REVIEW_REJECTEDA reviewer decides a manual-review session
onboardingStatusEXPIREDA session passes its expiry time
onboardingStatusDELETEDSession 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →