Agent skill

Certn Webhooks Skill

Receive and verify Certn webhooks. Use when setting up Certn background check webhook handlers, debugging Certn-Signature verification, or handling applicant report updates dispatched on report_status (IN_PROGRESS, COMPLETE) and result.

Install this skill

npx skills add hookdeck/webhook-skills --skill certn-webhooks


When to Use This Skill

  • How do I receive Certn webhooks?
  • How do I verify the Certn-Signature header?
  • How do I handle Certn applicant report updates when there is no event type in the body?
  • Why does Certn send unsigned webhooks, and why does verification fail?
  • Why is my Certn webhook signature verification failing?

Essential Code (USE THIS)

Signing is opt-in. When enabled, every Certn delivery carries Certn-Signature: t=<unix seconds>, v1=<hex>[, v1=<hex>] on one line, with a space after each comma. Each v1 is the HMAC-SHA256 (hex) of "<t>.<request body as text>", keyed with the signing secret as a UTF-8 string. Several v1 values are sent while a rolled secret is still active (48 hours), so accept the request if any v1 matches. Ignore schemes other than v1.

Verification (Node.js)

const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyCertn(rawBody, signatureHeader, secret) {
  if (!secret || !signatureHeader) return false;
  let t; const v1 = [];
  for (const part of signatureHeader.split(',')) {
    const [k, ...rest] = part.trim().split('=');
    if (k.trim() === 't') t = rest.join('=').trim();
    else if (k.trim() === 'v1') v1.push(rest.join('=').trim());
  }
  if (!t || !/^\d+$/.test(t) || v1.length === 0) return false;
  // Optional replay window, off by default: retries and replays keep the original timestamp
  const expected = Buffer.from(crypto.createHmac('sha256', secret)
    .update(`${t}.`).update(rawBody).digest('hex'));
  // exactly 64 hex chars first, then equal-length constant-time compare
  return v1.some((s) => /^[0-9a-f]{64}$/i.test(s) &&
    crypto.timingSafeEqual(Buffer.from(s.toLowerCase()), expected));
}

Verification (Python)

import hashlib, hmac

def verify_certn(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not secret or not signature_header:
        return False
    t, v1 = None, []
    for part in signature_header.split(","):
        k, sep, v = part.strip().partition("=")
        if sep and k.strip() == "t":
            t = v.strip()
        elif sep and k.strip() == "v1":
            v1.append(v.strip())
    if not t or not (t.isascii() and t.isdigit()) or not v1:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
    # bytes on both sides: a non-ASCII header gives a failed match (400), not a TypeError (500)
    return any(hmac.compare_digest(s.lower().encode(), expected) for s in v1)

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Timestamp window: off by default

Certn suggests rejecting old timestamps. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. The examples therefore skip the freshness check unless you set CERTN_TIMESTAMP_TOLERANCE_SECONDS to a positive number of seconds (default 0 = off). Use the applicant id plus report_status for idempotency instead. references/verification.md shows exactly where the window can be dropped or widened.

Setup

Signing is opt-in: turn on signed payloads in Certn and paste the signing secret. Without it, deliveries are unsigned and fail verification. In Certn, open the Partner tab, enter your webhook URL, click Enable Integration, and copy the secret string (it can't be changed later; contact Certn support if it is compromised). See references/setup.md.

Dispatch on report_status

Certn has no event types. It POSTs the applicant (report) object as results become available, so dispatch on its status fields:

FieldValues seen in the docsMeaning
report_statusIN_PROGRESS, COMPLETEProgress of the whole application order
result / result_labelNONE, CLEAREDApplication result (score)
check_executions[].statusWAITING_ON_CANDIDATE, COMPLETEProgress of each individual check

The body also has id, short_uid, applicant_type, order_status, certn_score, and an information object with the applicant's details. See references/overview.md.

Important Headers

HeaderDescription
Certn-Signaturet=<unix seconds>, v1=<hex HMAC-SHA256>[, v1=<hex>]; one line, several v1 during a secret roll

Environment Variables

CERTN_WEBHOOK_SECRET=your_certn_signing_secret   # secret string from the Certn Partner page
CERTN_TIMESTAMP_TOLERANCE_SECONDS=0              # optional; a positive value opts in to the replay window

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 certn --path /webhooks/certn

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →