# Guide to Certn Webhooks: Features and Best Practices

Certn webhooks keep a partner's systems up to date with background checks as they progress. Certn runs criminal record, identity, employment and reference checks, and when an applicant's report changes, it posts the updated applicant to your webhook URL so your hiring or onboarding workflow can move on without polling the API.

This guide covers how Certn webhooks are enabled and signed, what a delivery contains, how retries behave, and the practices that keep a Certn receiver reliable.

## What are Certn webhooks?

Certn has one webhook URL per partner. Every update for every applicant goes to it as a `POST`, and partner resellers share the same URL and secret across all their accounts and sub-accounts. You can't choose events, because Certn doesn't have any: the body is the applicant object itself, and the state of the background check lives in fields such as `status` and `report_status`.

Signing is opt-in. Once enabled, each request carries a `Certn-Signature` header that works like Stripe's: a `t=` timestamp and one or more `v1=` signatures, each a hex HMAC-SHA256 of `<t>.<raw body>`.

## Certn webhook features

| Feature | Details |
| --- | --- |
| Configuration | The **Partner** page: enter the URL and click **Enable Integration** |
| Scope | One URL and one secret per partner, shared by resellers' accounts and sub-accounts |
| Event selection | None. Every applicant update is sent |
| Event type | None. The body is the applicant object |
| Signature header | `Certn-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`, on one line |
| Scheme | HMAC-SHA256, hex digest. `v1` is the only live scheme |
| Signed content | `<t>.<raw body>` |
| Signing | Opt-in |
| Secret | A secret string on the Partner page, used as UTF-8 |
| Retries | `408`, `500`, `502`, `503` and `504`: up to three more attempts, with growing waits |
| Not retried | Any other status above `399` |
| Source IPs | Not published |

## Common events

There is no event catalogue. Each delivery is the full applicant object, with about 150 top-level keys in Certn's example. These are the ones that tell you what has changed:

| Field | Example value | Meaning |
| --- | --- | --- |
| `id` | `1ba124f1-d473-44c0-a066-d623b2cb6253` | The applicant ID, which correlates updates |
| `short_uid` | `pmndgAeH` | A short reference for the applicant |
| `status` / `status_label` | `Returned` | The applicant's state |
| `report_status` | `COMPLETE` | Progress of the whole report |
| `order_status` | `COMPLETE` | Progress of the order |
| `result` / `result_label` | `CLEARED` | The overall result |
| `*_result` | objects or `null` | One per check, such as `us_criminal_record_check_result`, `equifax_result` and `reference_result` |

Treat the body as a snapshot of the applicant, not as a description of one change. Compare it with what you stored last time to work out what happened, and branch on the status fields rather than on anything that names an event.

## Setting up Certn webhooks

1. In Certn, open the **Partner** tab.
2. Enter your webhook URL.
3. Click **Enable Integration**.
4. Copy the secret string and store it server-side, for example as `CERTN_WEBHOOK_SECRET`.

The URL can be changed at any time. Certn's setup instructions say the secret can't be changed and to contact support if it is compromised. The same page later describes rolling a secret from the dashboard with a 48-hour overlap, so check with Certn which applies to your account.

Signed payloads are opt-in, and Certn's documentation doesn't say where the switch is. Ask your Certn contact to turn signing on before you rely on it. When you receive Certn webhooks through Hookdeck, signing has to be on: create a Certn source, paste the signing secret, and note that unsigned deliveries fail verification.

## Securing Certn webhooks

Split `Certn-Signature` on commas, trim each element, take `t` and every `v1`, and ignore other schemes. Compute an HMAC-SHA256 of `<t>.<raw body>` with the secret as a UTF-8 key, and accept the request if any `v1` matches in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyCertnSignature(rawBody, header, secret) {
  if (!header || !secret) return false;
  let t;
  const candidates = [];
  for (const part of header.split(',')) {
    const [key, ...rest] = part.trim().split('=');
    if (key.trim() === 't') t = rest.join('=').trim();
    else if (key.trim() === 'v1') candidates.push(rest.join('=').trim());
  }
  if (!/^\d+$/.test(t || '') || candidates.length === 0) return false;

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'),
    'utf8'
  );
  return candidates.some((candidate) => {
    const received = Buffer.from(candidate.toLowerCase(), 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/certn', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.CERTN_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyCertnSignature(req.body, req.get('certn-signature'), secret)) {
    return res.sendStatus(401);
  }

  const applicant = JSON.parse(req.body.toString('utf8'));
  // Store the snapshot keyed by applicant.id, then act on report_status asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_certn_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret:
        return False
    t, candidates = None, []
    for part in header.split(","):
        key, sep, value = part.strip().partition("=")
        if sep and key.strip() == "t":
            t = value.strip()
        elif sep and key.strip() == "v1":
            candidates.append(value.strip())
    if not t or not (t.isascii() and t.isdigit()) or not candidates:
        return False
    expected = hmac.new(
        secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256
    ).hexdigest().encode("utf-8")
    return any(hmac.compare_digest(c.lower().encode("utf-8"), expected) for c in candidates)

```

Certn says you can reject a payload whose timestamp is too old, but names no tolerance. The [Certn webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/certn-webhooks) leaves that window off by default, so it accepts Hookdeck retries and replays of older events. If you turn the window on with `CERTN_TIMESTAMP_TOLERANCE_SECONDS`, note that Certn signs each of its own retries afresh, but Hookdeck retries and replays resend the original `t`, so any that arrive after the window has passed fail the check.

Certn's docs warn that Express doesn't give you the original body string without extra middleware. `express.raw()` on the route solves that.

> **Make Certn webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Certn-Signature` header, stores every applicant update, and delivers it to your services with retries and replay you control.

## Certn webhook limitations and pain points

### Unsigned until you opt in

**The Problem:** Deliveries arrive with no `Certn-Signature` header, so there is nothing to verify.

**Why It Happens:** Signing is optional and off unless it has been enabled for your partner account, and the documentation doesn't say where to turn it on.

**Workarounds:**

* Ask Certn to enable signed payloads before you go live.
* Reject requests without the header once signing is on, so a forged unsigned request isn't accepted.

**How Hookdeck Can Help:** Hookdeck's Certn source type expects the header. Unsigned deliveries are marked as failed verification and aren't delivered to your destinations, which tells you straight away that signing is off.

### No event types

**The Problem:** You can't route or filter on what happened, only on the applicant's current state.

**Why It Happens:** Certn posts the applicant object whenever it changes. No field or header says "report completed" or "check failed".

**Workarounds:**

* Store the last snapshot per applicant `id` and diff the status fields to decide what changed.
* Act on terminal states, such as `report_status` of `COMPLETE`, and treat earlier snapshots as progress.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on body fields such as `report_status`, so only completed reports reach the service that acts on them.

### Few retries, and some errors are final

**The Problem:** An update is lost after a short outage, or after a single `4xx` response.

**Why It Happens:** Certn retries only `408`, `500`, `502`, `503` and `504`, up to three more times. Any other status above `399`, including `401`, `404` or `429`, ends delivery on the first attempt.

**Workarounds:**

* Return `5xx`, not `4xx`, for transient problems on your side.
* Read the applicant from the Certn API after an outage to catch missed updates.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, so your own errors and downtime don't end Certn's delivery. It then delivers to your service with [automatic retries](/docs/retries).

## Best practices

### Verify against the raw body

The signed string uses the request body exactly as sent. Use `express.raw()` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after the check passes.

### Accept any matching `v1`

During a secret roll, Certn sends one `v1` per active secret. Check each one and accept the request if any matches.

### Make processing idempotent

There is no event ID. Key updates on the applicant `id` together with the status fields or `modified`, and make sure applying the same snapshot twice has no extra effect. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Protect the payload

The applicant object contains personal data and check results. Keep the endpoint on HTTPS, restrict who can read stored payloads, and drop fields you don't need.

### Acknowledge fast and process asynchronously

Verify, store the snapshot, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Certn posts the whole applicant object to one partner-wide URL, with no event types, and signs it only once signing is enabled. Verify `Certn-Signature` against `<t>.<raw body>` and accept any matching `v1`, keep timestamp checks optional, route on `report_status` and the other status fields, and return `5xx` rather than `4xx` for temporary failures so Certn retries.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Certn signature, and holds each applicant update until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Certn webhooks reliably in minutes.