# 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)

```javascript
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)

```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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/certn-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/certn-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/certn-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/certn-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/certn-webhooks/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:

| Field | Values seen in the docs | Meaning |
| --- | --- | --- |
| `report_status` | `IN_PROGRESS`, `COMPLETE` | Progress of the whole application order |
| `result` / `result_label` | `NONE`, `CLEARED` | Application result (score) |
| `check_executions[].status` | `WAITING_ON_CANDIDATE`, `COMPLETE` | Progress 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/certn-webhooks/references/overview.md).

## Important Headers

| Header | Description |
| --- | --- |
| `Certn-Signature` | `t=<unix seconds>, v1=<hex HMAC-SHA256>[, v1=<hex>]`; one line, several `v1` during a secret roll |

## Environment Variables

```bash
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

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

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/certn-webhooks/references/overview.md) - Certn webhook concepts, statuses, payload
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/certn-webhooks/references/setup.md) - Enabling webhooks and signing on the Partner page
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/certn-webhooks/references/verification.md) - Signature details, the timestamp window, and debugging