Gareth Wilson Gareth Wilson

Guide to Kombo Webhooks: Features and Best Practices

Published


Kombo webhooks tell your application when something changes in your customers' HR, recruiting or learning tools. Kombo is a unified API for HRIS, ATS and LMS integrations, and its webhooks tell you when synced data changes, when a customer connects or disconnects an integration, when an integration needs reconnecting, or when an assessment is ordered.

This guide covers how Kombo webhooks are configured and signed, the webhook types and their payloads, Kombo's retry behaviour, and the best practices for running a Kombo receiver in production.

What are Kombo webhooks?

Kombo talks about two kinds of webhook. Upstream webhooks are the ones Kombo receives from connected tools such as Personio. Downstream webhooks are the ones Kombo sends to you, and those are the subject of this guide.

You configure downstream webhooks in the Kombo dashboard, separately for each environment. Every request is a JSON POST with an id, a type and a data object, and an X-Kombo-Signature header. The signature is an HMAC-SHA256 of the raw request body, keyed with your Kombo Webhook Secret and encoded as base64url without padding.

Two details matter for verification. Kombo serialises the body with two-space indentation, and that exact string is what it signs. And the secret is per Kombo environment, so the development secret won't verify production deliveries.

Kombo webhook features

FeatureDetails
ConfigurationKombo dashboard, Configuration > Webhooks, per environment
Signature headerX-Kombo-Signature
SchemeHMAC-SHA256, base64url without padding
Signed contentThe raw body, which Kombo serialises with two-space indentation
SecretThe Kombo Webhook Secret, one per account and environment, generated with the first webhook
Replay windowNone. No timestamp is sent or signed
RetriesUp to 5, after 1, 2, 4, 8 and 16 seconds
Retried on429 (honouring Retry-After), 5xx, connection errors, Cloudflare 1018 (409)
Not retriedOther 4xx responses and DNS ENOTFOUND
Auto-disableAfter more than 7 days of 404 or 410 responses
TestingA test button per webhook sends dummy data with the real schema
Response timeout and source IPsNot documented

Common events

WebhookSent when
data-changedData changes in Kombo, from a regular sync or an upstream webhook. Debounced to at most one every 30 seconds
integration-createdAn integration is created
integration-deletedAn integration is deleted
integration-state-changedAn integration's state changes: ACTIVE, INVALID (needs reconnecting) or INACTIVE (paused)
issue-status-changedKombo raises or resolves an issue on an integration
connection-flow-failedAn error occurs during the connection flow. The flow can still complete afterwards
assessment:order-receivedAn assessment is ordered for a candidate from a customer's tool
sync-finishedA sync finishes, whatever its result. Deprecated: use data-changed instead

Here is Kombo's data-changed example:

{
  "id": "FhghqjnCi9WuAoLT8Z75CFcs",
  "type": "data-changed",
  "data": {
    "integration_id": "bombohr:ats-dev",
    "integration_tool": "bombohr",
    "integration_category": "ATS",
    "changed_models": [{ "name": "ats_applications" }]
  }
}

data-changed tells you which models changed for which integration, not the changed records. Fetch them from Kombo's API with the updated_after filter. The first data-changed goes out immediately. After that Kombo collects changes for 30 seconds and sends them as one webhook, and it doesn't wait for a sync to finish, so a single sync can produce several.

integration-state-changed also carries qa_status (PENDING, PASSED or FAILED) and setup_status (INCOMPLETE, FINAL_SYNC_PENDING or COMPLETED). issue-status-changed carries change (raised or resolved), an issue object whose type Kombo says is an open set, and the affected integration. Integration IDs look like personio:CBNMt7dSNCzBdnRTx87dev4E, tool name first.

Setting up Kombo webhooks

  1. Open the Kombo dashboard in the environment you want, and go to Configuration > Webhooks.
  2. Add a webhook with your endpoint URL.
  3. After you create the first webhook, Kombo generates and shows the Kombo Webhook Secret. Store it server-side, for example as KOMBO_WEBHOOK_SECRET, and keep production and development secrets apart.
  4. Use the test button next to the webhook to send a request with dummy data. Be careful in production: the test sends invalid data to your endpoint.

If you use Hookdeck, create a source with the Kombo source type, set the Kombo Webhook Secret on it, and use the Hookdeck source URL as the webhook URL. Use a separate source for each Kombo environment.

Securing Kombo webhooks

Kombo's instructions are to decode X-Kombo-Signature from base64url (without padding) to bytes, compute an HMAC-SHA256 of the raw body with the secret's bytes as the key, and compare the two byte strings in constant time.

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

function verifyKomboSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;

  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(signatureHeader.trim(), 'base64url');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

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

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.type, then process async.
  res.sendStatus(200);
});

The same check in Python:

import base64
import binascii
import hashlib
import hmac
import os


def verify_kombo_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    signature = signature_header.strip()
    try:
        received = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4))
    except (binascii.Error, ValueError):
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    return hmac.compare_digest(received, expected)


# In FastAPI: raw_body = await request.body()
# verify_kombo_signature(raw_body, request.headers.get("x-kombo-signature"),
#                        os.environ["KOMBO_WEBHOOK_SECRET"])

If your framework has already parsed the JSON, Kombo says you can re-encode it with two-space indentation (JSON.stringify(body, null, 2) in JavaScript) and verify that. Using the raw body is simpler and doesn't depend on reproducing Kombo's formatting exactly.

To check your verifier, use the secret kombo-test-secret and this exact body, with two-space indentation and no trailing newline:

{
  "id": "5gjAtURLPbnTiwgkaBfiA3WJ",
  "type": "integration-deleted",
  "data": {
    "id": "personio:CBNMt7dSNCzBdnRTx87dev4E",
    "tool": "personio",
    "category": "HRIS"
  }
}

The X-Kombo-Signature header should be:

N-fb5Yt4w9Rq3bIpyfBIi3CvGKGjHKomtyPW01Z7I7I

Make Kombo webhooks production-ready. Hookdeck Event Gateway verifies the X-Kombo-Signature header on every delivery and keeps retrying to your service long after Kombo's 31-second retry window has passed.

Kombo webhook limitations and pain points

Retries last about half a minute

The Problem: A short outage or a deploy on your side loses webhooks for good.

Why It Happens: Kombo retries a failed delivery up to 5 times, after 1, 2, 4, 8 and 16 seconds. That's 31 seconds of backoff in total. If your endpoint is down for longer, the event is dropped.

Workarounds:

  • Keep the webhook endpoint highly available and separate from deploys of the rest of your application.
  • For data-changed, recover by fetching with updated_after set to the last time you processed successfully, rather than relying on every webhook arriving.
  • Check webhook delivery failures in the Kombo dashboard's logs after any incident.

How Hookdeck Can Help: Hookdeck accepts each Kombo delivery immediately and stores it, then retries delivery to your service for as long as you configure and lets you replay events after an outage. Kombo's short schedule stops mattering.

Some failures are never retried

The Problem: A bug in your verification or routing silently loses events.

Why It Happens: Kombo doesn't retry 4xx responses other than 429. A 401 from a mismatched secret, a 400 from a parsing error, or a 404 from a wrong path all count as permanent failures, and so does a URL whose hostname doesn't resolve.

Workarounds:

  • Return 5xx, not 4xx, for failures you want Kombo to retry, such as a database being unavailable.
  • Alert on any 401 from the webhook route: it usually means the wrong environment's secret.
  • Use the test button after every configuration change.

How Hookdeck Can Help: Hookdeck keeps every request it receives, including ones that fail verification, so you can see what went wrong, fix the secret, and replay from Hookdeck.

Body formatting breaks verification

The Problem: Signatures never match, though the secret is right.

Why It Happens: Kombo signs the body as it serialised it, with two-space indentation. A framework that parses JSON and hands you an object, and code that re-serialises it compactly, produces different bytes.

Workarounds:

  • Verify the raw body before any parsing.
  • If you only have the parsed object, re-encode it with JSON.stringify(body, null, 2).
  • Decode the header with base64url, not standard base64, and compare bytes.

How Hookdeck Can Help: Hookdeck's Kombo source type verifies the raw body as received, so your service gets requests that have already been checked.

Endpoints that 404 get disabled

The Problem: A webhook stops sending after a routing change.

Why It Happens: If your endpoint returns 404 or 410 for more than 7 days, Kombo disables the webhook. You have to re-enable it in the dashboard.

Workarounds:

  • Keep the webhook path stable, or update the URL in Kombo before you change routes.
  • Re-enable disabled webhooks from the dashboard after fixing the endpoint.

How Hookdeck Can Help: The Hookdeck source URL stays the same when your own routes change. Update the destination in Hookdeck and Kombo keeps sending.

Best practices

Treat data-changed as a signal to fetch

data-changed names the models that changed, not the data. Fetch with updated_after from your last successful sync, not from sync_started_at on the deprecated sync-finished webhook, which Kombo warns can miss changes that arrived through upstream webhooks.

Use the right secret per environment

Production and development each have their own Kombo Webhook Secret. Store them separately and pick the right one by endpoint, not by a shared environment variable.

Deduplicate on id

Kombo says webhooks can be delivered more than once because of retries. Store each id before processing and skip ones you have seen. See our guide to webhook idempotency.

Treat issue types as an open set

issue-status-changed will gain new issue.type values over time. Handle the ones you know and log the rest.

Acknowledge fast and process asynchronously

Return a 2xx as soon as the event is verified and stored. Kombo asks for this, and its retry window is too short to absorb slow handlers. See why to process webhooks asynchronously.

Conclusion

Kombo signs every downstream webhook with an unpadded base64url HMAC-SHA256 of the raw, two-space-indented body in X-Kombo-Signature, keyed with the environment's Kombo Webhook Secret. Verify against the raw bytes with the right environment's secret, deduplicate on id, treat data-changed as a prompt to fetch, and remember that Kombo's retries cover only about 31 seconds and skip most 4xx responses.

Hookdeck Event Gateway sits in front of your receiver, verifies every Kombo signature, and holds each event until your service has processed it, however long that takes.

Get started with Hookdeck for free and handle Kombo 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.