# Guide to Check Webhooks: Features and Best Practices

Check webhooks tell your platform what is happening in payroll as it happens: a payroll is approved or paid, a payment fails, a bank account is verified, an employee finishes onboarding, or a tax filing changes state. If you build payroll into your product with Check, the embedded payroll API, webhooks are how those changes reach your database and your users without polling Check's API.

This guide covers how Check webhook configs are created and signed, how the topic header and event wrapper fit together, how Check retries, deactivates and reports on deliveries, and the best practices for running a Check receiver in production.

## What are Check webhooks?

A Check webhook config is a URL you register through Check's API. Once it exists, Check sends a signed JSON `POST` to it for every webhook it generates for your account. There is no topic subscription: every active config receives every webhook, and your handler decides what to act on.

Each request carries a `Check-Topic` header naming the resource the webhook is about, such as `payroll`, `payment` or `employee`. The body wraps the change in two fields: `event`, which names what happened (`created`, `updated`, `deleted`, `status_change`), and `data`, the serialised object. The two together tell you what you received. `event: "created"` on its own could be an employee, a bank account or a payroll.

Every config has a generated `key`. Check signs each request with an HMAC-SHA256 of the raw body using that key and sends the result as lowercase hex in `Check-Signature`.

Sandbox and live are separate. Configs created on `sandbox.checkhq.com` receive sandbox events, configs created on `api.checkhq.com` receive live events, and the `Check-Live` header says which one a request came from.

## Check webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST /webhook_configs` on `api.checkhq.com` or `sandbox.checkhq.com` |
| Subscription model | None. Every active config receives every webhook |
| Headers | `Check-Live`, `Check-WebhookEvent-ID`, `Check-Signature`, `Check-Topic`, plus deprecated `X-` equivalents |
| Body | `{ "event": ..., "data": ... }`, except the ping |
| Scheme | HMAC-SHA256 of the raw body, hex-encoded |
| Secret | The config's generated `key`, used as UTF-8 text |
| Replay protection | None in the scheme: no timestamp |
| Payload version | `api_version` on the config, or your account default when `null` |
| Response window | `2xx` within 5 seconds |
| Retries (live) | Up to four days, exponential backoff from 10 seconds to a 6-hour maximum |
| Retries (sandbox) | 10 attempts in total |
| Deactivation | Live: no `2xx` for 7 days after at least one request. Sandbox: 24 hours |
| Delivery log | `GET /webhook_deliveries`, with status and attempts but no payloads |
| Manual retry | `POST /webhook_deliveries/{id}/retry`, sandbox only |
| Source IPs | `44.195.7.1`, `3.228.118.62`, `52.0.144.131` |
| Testing | `POST /webhook_configs/{id}/ping` |

## Common events

Check groups its webhooks into 32 topics. The `Check-Topic` header carries the topic, and the body's `event` carries the action. The topics most integrations handle first:

| Topic (`Check-Topic`) | Events (`event`) | What it covers |
| --- | --- | --- |
| `payroll` | `created`, `updated`, `deleted`, `status_change` | Payroll lifecycle. `updated` needs API version 2025-01-01 or later, where `status_change` is deprecated |
| `payment` | `status_change` | A payment moves to processing, paid, failed or refunded |
| `employee` | `created`, `updated`, `deleted` | Profile and onboarding changes. Terminations arrive as `updated` |
| `contractor` | `created`, `updated`, `deleted` | Contractor profile and onboarding changes |
| `bank_account` | `created`, `updated`, `deleted` | Bank accounts and their verification status |
| `company` | `created`, `updated` | Employer records |
| `company_processing_status` | `status_change` | A company's processing period or ability to run payroll changes |
| `employee_tax_document`, `company_tax_document` and other document topics | `created`, `deleted` | Tax and authorisation documents, for example a W-4 submitted in onboarding |
| `company_filing` | `created`, `updated`, `deleted` | Company tax filings |
| `tax_package` | `status_change` | A tax package finished or failed generating |
| `notification` | `created` | A Check notification, such as a failed payroll debit, was created |

Check's own advice is to treat a webhook as a prompt to re-fetch. The payload reflects the object when the webhook was generated and may be stale by the time you read it, so fetch the current version from the API before you rely on it.

Here is Check's example `bank_account` webhook, sent with `Check-Topic: bank_account`:

```json
{
  "event": "created",
  "data": {
    "id": "bnk_BcuzUyfy6Rt2Woa6TAHz",
    "company": "com_cHVEOcdE6bzMiBxpOzTQ",
    "status": "ownership_verified",
    "disabled_reason": null,
    "plaid_bank_account": {
      "name": "Plaid Checking",
      "plaid_public_token": "public-sandbox-023e3f0d-bcd8-46ee-b1dd-fcb67bcb4372",
      "institution_name": "Chase",
      "mask": "0000",
      "type": "depository",
      "subtype": "checking",
      "verified": true,
      "microdeposit_verification_status": null
    }
  }
}

```

The ping is the one exception to the wrapper. It is sent with the `ping` topic and a flat body, `{"message": "Test webhook"}`, so a handler that assumes `event` and `data` always exist will fail on it.

## Setting up Check webhooks

Check has no dashboard screen for webhook configs. You create them through the API, separately for sandbox and live.

1. Create a config with your endpoint URL:
  
  
  ```bash
  curl https://sandbox.checkhq.com/webhook_configs \
    -X POST \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://example.com/webhooks/check"}'
  
  ```
  
  
2. Copy the `key` from the response and store it server-side, for example as `CHECK_WEBHOOK_KEY`. It stays readable through `GET /webhook_configs`, but there's no reason to fetch it at runtime.
3. Optionally set `api_version` on the config, so payloads keep the same shape when your account's default version changes.
4. Send a ping with `POST /webhook_configs/{id}/ping` and check that your endpoint returns a `2xx`. The ping goes through the same signing pipeline as real webhooks, so it tests your verification too.
5. Repeat on `api.checkhq.com` with your live API key when you are ready for production.

To stop deliveries without deleting the config, `PATCH` it with `"active": false`. Check recommends this over deleting. Webhooks generated while a config is inactive are never sent later, though, so pausing loses events.

If you use Hookdeck, create a source with the Check source type, paste the config's `key` into it, and register the Hookdeck source URL as the config's `url`. Use separate sources for sandbox and live, since each environment has its own config and key.

## Securing Check webhooks

To verify a request, compute an HMAC-SHA256 of the raw request body using the config's `key` as the secret, hex-encode it, and compare it in constant time with `Check-Signature`. Two details trip people up:

* The key looks like 40 hex characters, but you use it as a plain UTF-8 string. Don't hex-decode it.
* The signature is hex, not base64. Check's docs point this out specifically, because many generic HMAC examples use base64.

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

function verifyCheckSignature(rawBody, signatureHeader, key) {
  if (!signatureHeader || !key) return false;

  const signature = signatureHeader.trim().toLowerCase();
  if (!/^[0-9a-f]{64}$/.test(signature)) return false;

  const expected = crypto.createHmac('sha256', key).update(rawBody).digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/check', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyCheckSignature(req.body, req.get('check-signature'), process.env.CHECK_WEBHOOK_KEY)) {
    return res.sendStatus(401);
  }

  const topic = req.get('check-topic');
  const payload = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the Check-WebhookEvent-ID header, route on topic + payload.event,
  // then process async. The ping has no event or data.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_check_signature(raw_body: bytes, signature_header, key: str) -> bool:
    if not signature_header or not key:
        return False
    expected = hmac.new(key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header.strip().lower())

# In FastAPI: raw_body = await request.body()
# verify_check_signature(raw_body, request.headers.get("check-signature"),
#                        os.environ["CHECK_WEBHOOK_KEY"])

```

To check your verifier, use the key `2bfce877d2e64ea6a8dd45ffb2ce897eb4d97485` (the sample key from Check's webhook config docs) and this exact body:

```
{"message":"Test webhook"}

```

The `Check-Signature` header should be:

```
80150ec94d51337c3161a103a984557063819dace9ed2a34340528e8956dd2c4

```

If you hex-decode the key or base64-encode the digest, you get a different value.

Check also publishes the three IP addresses its webhooks come from. An allowlist is a reasonable extra layer, but keep verifying signatures, and remember that if Hookdeck or another proxy sits in front of your service, requests reach you from that proxy's addresses instead.

> **Make Check webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the hex `Check-Signature` on every delivery, answers inside Check's 5-second window, and holds payroll and payment events until your service has processed them.

## Check webhook limitations and pain points

### Five seconds to respond

**The Problem:** The same webhook arrives several times, even though your handler eventually returned `200`.

**Why It Happens:** Check counts a delivery as failed if it doesn't get a `2xx` within 5 seconds, even if your server answers later. Payroll handlers that re-fetch objects from the API or write to several tables can exceed that under load.

**Workarounds:**

* Verify, store the request, return `200`, and do the re-fetch and processing in a background job.
* Deduplicate on `Check-WebhookEvent-ID`, which Check provides for exactly this case.

**How Hookdeck Can Help:** Hookdeck acknowledges Check immediately and gives your service longer to respond, so slow processing doesn't trigger Check's retries.

### Pausing or deactivation loses events

**The Problem:** Webhooks generated during an outage or a pause never arrive.

**Why It Happens:** Live retries stop after four days, and sandbox stops after 10 attempts. A live config that has received at least one request but returns no `2xx` for 7 days is deactivated (24 hours in sandbox). Setting `active` to `false` yourself has the same effect: Check doesn't queue webhooks for inactive configs. Live deliveries can't be retried manually; the retry endpoint is sandbox-only.

**Workarounds:**

* Watch `GET /webhook_deliveries?status=failed` and `status=retrying` to catch problems early.
* After an outage, reconcile by listing the affected resources through the API.
* Re-enable a deactivated config with `PATCH /webhook_configs/{id}` once your endpoint is healthy.

**How Hookdeck Can Help:** Hookdeck keeps accepting Check's deliveries while your service is down, so Check never sees the failures that lead to deactivation. Events wait in Hookdeck, and you replay them when you're ready.

### Every config gets every webhook

**The Problem:** A service that only cares about payments has to receive, verify and discard employee, document and filing webhooks too.

**Why It Happens:** Check has no per-config topic filter. Every active config receives every webhook your account generates.

**Workarounds:**

* Return `200` quickly for topics you don't handle, as Check recommends, so they never count as failures.
* Use one receiver and fan out internally by `Check-Topic`.

**How Hookdeck Can Help:** Hookdeck filters on headers as well as bodies, so you can route `Check-Topic: payment` to one service and `employee` to another, and drop topics nobody uses, from one Check config.

### The topic and the action are in different places

**The Problem:** Stored payloads are ambiguous, and handlers mix up events with the same name.

**Why It Happens:** The body's `event` is just the action, such as `created`. The resource type is only in the `Check-Topic` header. The ping has neither `event` nor `data`.

**Workarounds:**

* Build a routing key from both, for example `${topic}.${event}`, and store the topic with any payload you persist.
* Handle the ping separately before you read `event`.

**How Hookdeck Can Help:** Hookdeck records headers alongside every event, so the topic is never lost, and its filters can match the header and body together.

### Payloads change shape with the API version

**The Problem:** A handler that worked for months starts failing after an account-level API version change, for example when payroll `status_change` webhooks are replaced by `updated`.

**Why It Happens:** Versioned objects in `data` follow the config's `api_version`, or your account default when it is `null`.

**Workarounds:**

* Set `api_version` on each config explicitly, and change it deliberately when you upgrade.
* Test the new version against a sandbox config before switching live.

**How Hookdeck Can Help:** Hookdeck transformations can reshape payloads in transit while you migrate handlers, and its event log keeps examples of both shapes for testing.

## Best practices

### Re-fetch before acting

Check recommends querying the API for the latest version of the object instead of trusting the payload, which may already be out of date. Do this in your background job, not in the request. Remember the API rate limit, which starts at 10 requests per second and scales with the number of payees you paid in the prior month.

### Deduplicate on `Check-WebhookEvent-ID`

Store each ID atomically before processing, for example with a unique index or Redis `SET NX`. The same ID appears in the deliveries API, which makes it easy to trace a webhook end to end. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep sandbox and live apart

Use different endpoints or sources for each environment, and check `Check-Live` before touching production data. Sandbox gives up after 10 attempts and deactivates after 24 hours without a `2xx`, so expect to re-enable sandbox configs more often.

### Rotate keys by adding a config

Because every active config receives every webhook, you can rotate a key by creating a second config, deploying its key, then deactivating or deleting the old one. Deduplicate on `Check-WebhookEvent-ID` during the overlap, since both configs receive the same webhooks.

### Acknowledge fast and process asynchronously

Five seconds includes network time. Verify, persist, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Check sends every webhook to every active config, puts the resource type in `Check-Topic` and the action in the body's `event`, and signs the raw body with a hex HMAC-SHA256 keyed with the config's key as plain text. Verify before parsing, deduplicate on `Check-WebhookEvent-ID`, re-fetch objects before acting, and answer within 5 seconds, because pausing, deactivation and exhausted retries all lose webhooks for good.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Check signature, routes topics to the services that need them, and holds payroll events while your service is unavailable.

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