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_, sandbox only |
| Source IPs | 44.195.7.1, 3.228.118.62, 52.0.144.131 |
| Testing | POST /webhook_ |
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_ | 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_:
{
"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.
Create a config with your endpoint URL:
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"}'Copy the
keyfrom the response and store it server-side, for example asCHECK_WEBHOOK_KEY. It stays readable throughGET /webhook_configs, but there's no reason to fetch it at runtime.Optionally set
api_versionon the config, so payloads keep the same shape when your account's default version changes.Send a ping with
POST /webhook_and check that your endpoint returns aconfigs/{id}/ping 2xx. The ping goes through the same signing pipeline as real webhooks, so it tests your verification too.Repeat on
api.checkhq.comwith 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.
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:
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 verifies the hex
Check-Signatureon 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_anddeliveries?status=failed status=retryingto catch problems early. - After an outage, reconcile by listing the affected resources through the API.
- Re-enable a deactivated config with
PATCH /webhook_once your endpoint is healthy.configs/{id}
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
200quickly 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_versionon 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.
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.
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 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 for free and handle Check webhooks reliably in minutes.