Gareth Wilson Gareth Wilson

Guide to Check Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI only: POST /webhook_configs on api.checkhq.com or sandbox.checkhq.com
Subscription modelNone. Every active config receives every webhook
HeadersCheck-Live, Check-WebhookEvent-ID, Check-Signature, Check-Topic, plus deprecated X- equivalents
Body{ "event": ..., "data": ... }, except the ping
SchemeHMAC-SHA256 of the raw body, hex-encoded
SecretThe config's generated key, used as UTF-8 text
Replay protectionNone in the scheme: no timestamp
Payload versionapi_version on the config, or your account default when null
Response window2xx 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
DeactivationLive: no 2xx for 7 days after at least one request. Sandbox: 24 hours
Delivery logGET /webhook_deliveries, with status and attempts but no payloads
Manual retryPOST /webhook_deliveries/{id}/retry, sandbox only
Source IPs44.195.7.1, 3.228.118.62, 52.0.144.131
TestingPOST /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
payrollcreated, updated, deleted, status_changePayroll lifecycle. updated needs API version 2025-01-01 or later, where status_change is deprecated
paymentstatus_changeA payment moves to processing, paid, failed or refunded
employeecreated, updated, deletedProfile and onboarding changes. Terminations arrive as updated
contractorcreated, updated, deletedContractor profile and onboarding changes
bank_accountcreated, updated, deletedBank accounts and their verification status
companycreated, updatedEmployer records
company_processing_statusstatus_changeA company's processing period or ability to run payroll changes
employee_tax_document, company_tax_document and other document topicscreated, deletedTax and authorisation documents, for example a W-4 submitted in onboarding
company_filingcreated, updated, deletedCompany tax filings
tax_packagestatus_changeA tax package finished or failed generating
notificationcreatedA 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:

{
  "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:

    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.
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-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.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.