# Guide to Checkout.com Webhooks: Features and Best Practices

Checkout.com webhooks notify your systems when something happens to a payment or the account around it: an authorization is approved, funds are captured, a refund goes through, a cardholder raises a dispute. If you're fulfilling orders, updating a ledger, or running a disputes workflow on top of Checkout.com, webhooks are how those systems learn about asynchronous outcomes without polling the API.

This guide covers how Checkout.com webhooks work on the current platform, the two ways to configure them, the `Cko-Signature` verification scheme (and the optional `Authorization` key that sits next to it), the delivery and ordering guarantees, and the best practices for production.

## What are Checkout.com webhooks?

Checkout.com webhooks are HTTP callbacks that notify your application when events occur on your Checkout.com account. On the current platform, a webhook is an action inside a workflow: a set of event conditions plus a `webhook` action that POSTs the event to your URL. Every event follows the same JSON envelope: an event `id` prefixed `evt_`, a snake_case `type`, a schema `version`, a timestamp, and a `data` object carrying the event-specific details, such as the payment, the dispute, or the 3DS session.

## Checkout.com webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (Developers > Webhooks > Create configuration) or Workflows API (`POST /workflows` with a `webhook` action) |
| Authentication | HMAC-SHA256 (hex) over the raw body in `Cko-Signature`, keyed with the webhook signature key; optional static key in `Authorization` |
| Envelope | `{ "id", "type", "version", "created_on", "data", "_links" }`, with event data nested under `data` |
| Event types | 140+ across Gateway, Disputes, Fraud, Authentication, Issuing, Identities, Platforms, Settlements and more |
| Timeout | Acknowledge every webhook within 10 seconds |
| Retries | Up to 8 retries at 5 min, 10 min, 15 min, 30 min, 1 hour, 4 hours, 12 hours and 12 hours; the webhook is canceled if all fail |
| Delivery | At least once, with no ordering guarantee |
| Manual resend | Past webhooks can be resent from the Dashboard or the API |
| Environments | Sandbox and live have separate Dashboards, API keys and webhook configurations |

## Common events

Checkout.com event names are the `type` values, always snake_case:

| Event | Fires when |
| --- | --- |
| `payment_approved` | An authorization succeeds |
| `payment_declined` | An authorization is declined |
| `payment_pending` | A payment is waiting on a next step |
| `payment_captured` | Funds are captured |
| `payment_capture_declined` | A capture attempt fails |
| `payment_refunded` | A refund succeeds |
| `payment_voided` | An authorization is voided |
| `payment_expired` | An alternative payment method payment expires |
| `card_verified` | A card verification (zero-amount authorization) succeeds |
| `dispute_received` | A cardholder raises a dispute |
| `dispute_evidence_required` | Evidence is needed before the dispute deadline |
| `dispute_won` / `dispute_lost` | A dispute is resolved for or against you |
| `fraud_reported` | A payment is reported as fraudulent |
| `authentication_approved` | 3DS authentication succeeds |

Branch on the top-level `type` field; the event-specific data lives under `data`, where `data.id` is the payment (`pay_...`) on payment events and the dispute (`dsp_...`) on dispute events.

> See Checkout.com webhook payloads in action. Inspect and replay sample Checkout.com webhook payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## Setting up Checkout.com webhooks

In the Dashboard, go to Developers > Webhooks and select Create configuration. Enter a name and your endpoint URL, then select Generate key for the signature key and copy it into your environment as your webhook secret. You can optionally generate an authorization header key as well, and add extra static headers if your routing needs them. Select the events to subscribe to, choose the entities or processing channels, and select Create webhook. Behind the scenes, the Dashboard builds a workflow for you.

Via the API, create the workflow directly. Event conditions are grouped by source (`gateway`, `dispute`, and so on), and the webhook action carries the URL, any static headers, and the signature key:

```bash
curl -X POST "https://{prefix}.api.checkout.com/workflows" \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production webhook",
    "conditions": [
      {
        "type": "event",
        "events": {
          "gateway": ["payment_approved", "payment_captured", "payment_refunded"],
          "dispute": ["dispute_received"]
        }
      }
    ],
    "actions": [
      {
        "type": "webhook",
        "url": "https://your-app.com/webhooks/checkout-com",
        "signature": {
          "method": "HMACSHA256",
          "key": "your-signature-key"
        }
      }
    ]
  }'

```

The `{prefix}` is unique to your account, and sandbox uses `{prefix}.api.sandbox.checkout.com`. Sandbox and live are configured separately, so a webhook created in sandbox does not exist in live until you create it there too, with its own signature key.

There is no handshake or special test event. To exercise the integration, create a sandbox payment: an authorization produces `payment_approved` and a capture produces `payment_captured`.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 checkout-com --path /webhooks/checkout-com` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Paste the generated URL into the webhook's endpoint URL in the sandbox Dashboard.

## Securing Checkout.com webhooks

Checkout.com hashes the raw request body with HMAC-SHA256, using your webhook signature key as the key, and sends the hex-encoded result in the `Cko-Signature` header. The value is the bare digest, with no `sha256=` prefix, timestamp, or version tag.

Verify against the raw request body: Checkout.com warns that deserializing and re-serializing JSON can change number precision and special characters, so in Express that means `express.raw()` on the route. Use the signature key as-is, as a UTF-8 string, without base64 or hex decoding it, and compare with a timing-safe function, checking lengths first because `timingSafeEqual` throws on a mismatch. If you configured an authorization header key, check it too; it arrives verbatim in `Authorization` with no `Bearer ` prefix:

```javascript
const crypto = require("crypto");

function timingSafeCompare(a, b) {
  const left = Buffer.from(String(a), "utf8");
  const right = Buffer.from(String(b), "utf8");
  // timingSafeEqual throws on mismatched lengths
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}

function verifyCkoSignature(rawBody, signatureHeader, signatureKey) {
  if (!signatureHeader || !signatureKey) return false; // fail closed

  const expected = crypto
    .createHmac("sha256", signatureKey) // key used as-is, not decoded
    .update(rawBody) // raw bytes, never re-serialized JSON
    .digest("hex");

  return timingSafeCompare(signatureHeader.trim().toLowerCase(), expected);
}

function verifyAuthorizationKey(header, expectedKey) {
  if (!expectedKey) return true; // optional; only enforced when configured
  return timingSafeCompare(header || "", expectedKey);
}

app.post("/webhooks/checkout-com", express.raw({ type: "*/*" }), (req, res) => {
  const signature = req.headers["cko-signature"];

  if (!verifyAuthorizationKey(req.headers["authorization"], process.env.CHECKOUT_WEBHOOK_AUTHORIZATION_KEY)) {
    return res.status(401).send("Invalid Authorization key");
  }

  if (!verifyCkoSignature(req.body, signature, process.env.CHECKOUT_WEBHOOK_SIGNATURE_KEY)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));

  // Acknowledge within 10 seconds, then process asynchronously
  res.status(200).send("OK");

  switch (event.type) {
    case "payment_captured":
      // Fulfil the order (data.amount is in the minor currency unit)
      break;
    case "dispute_received":
      // Gather evidence; data.payment_id points at the disputed payment
      break;
  }
});

```

Note the key: the signature key is the value generated for the webhook (or set as `signature.key` on the workflow action), not your `sk_...` secret API key.

> Make Checkout.com webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies deliveries at the edge, deduplicates, and durably queues every event with replay for anything that fails.

## Checkout.com webhook limitations and pain points

### No timestamp, so no replay window

The Problem: The `Cko-Signature` covers the body and nothing else. There is no timestamp header and no signed timestamp, so a captured request replayed later carries a perfectly valid signature. A handler that only verifies the HMAC will process the replay as a new event.

Why It Happens: Checkout.com's scheme is a plain HMAC of the raw body. Unlike schemes that sign `timestamp.body`, there is nothing in the signed content that ages out.

Workarounds:

* Treat the envelope `id` (`evt_...`) as your idempotency key and record every processed id.
* Keep processed ids for at least the length of the retry schedule (roughly 30 hours), and longer if you can, since manual resends can come later.
* Don't add a timestamp tolerance check of your own: there is no timestamp to check, and inventing one will reject genuine deliveries.

How Hookdeck Can Help: Hookdeck verifies the `Cko-Signature` HMAC at the edge, and [deduplication](/docs/deduplication) rules can drop repeated deliveries based on the event `id` before they reach your handler, so replays and retry races don't turn into double fulfillment.

### Events arrive out of order

The Problem: Checkout.com guarantees at-least-once delivery, but says the order may vary. A `payment_captured` can land before the `payment_approved` for the same payment, and a retried event can arrive hours after later events have already been processed.

Why It Happens: Each failed delivery follows its own retry schedule, so a single slow or failed attempt is enough to reorder the stream.

Workarounds:

* Make each handler correct on its own: upsert the payment's state instead of requiring a predecessor event.
* Compare each event against stored state and ignore transitions that would move an order backwards.

How Hookdeck Can Help: Every delivery is logged with its headers, body and timestamps, so you can see the exact sequence your handler received for a payment, and replay specific events after fixing a state-machine bug instead of reconstructing what happened from application logs.

### A 10-second budget and a finite retry schedule

The Problem: Your endpoint must acknowledge each webhook within 10 seconds. Anything slower counts as a failure, and after 8 retries over roughly 30 hours, Checkout.com cancels the webhook. A longer outage, or a bug that keeps returning errors, means events you'll have to resend manually.

Why It Happens: The retry intervals are fixed (5 minutes, 10 minutes, 15 minutes, 30 minutes, 1 hour, 4 hours, 12 hours, 12 hours), and a timeout is treated the same as an error response.

Workarounds:

* Verify, enqueue and return 2xx immediately; do the database writes and downstream calls afterwards.
* Use the Dashboard or API resend to recover events after an incident, and monitor your endpoint's error rate so you catch failures early in the retry window.

How Hookdeck Can Help: Hookdeck acknowledges Checkout.com immediately and durably queues events ahead of your endpoint, with [automatic retries](/docs/retries) on your own schedule, [Issues](/docs/issues) that alert you when deliveries fail, and replay for any event, so a slow handler or an outage on your side no longer runs down Checkout.com's retry clock.

### Two keys, and only one protects the body

The Problem: Each webhook can carry a signature key, an authorization header key, both, or neither. The `Authorization` value is a static secret sent verbatim: it proves the sender knows the key but says nothing about whether the body was altered. Teams that check only `Authorization`, or that use their `sk_...` secret API key as the HMAC key, end up with either weak verification or every delivery failing.

Why It Happens: Both keys are optional and independent in the webhook configuration, and the signature key is a separate value from the API keys used to call Checkout.com.

Workarounds:

* Always configure a signature key and verify `Cko-Signature`; treat `Authorization` as an extra check, never a replacement.
* Store the sandbox and live signature keys separately, since each environment has its own configuration.

How Hookdeck Can Help: Hookdeck verifies `Cko-Signature` (HMAC-SHA256, hex, over the raw body) at the edge, so unsigned or tampered requests never reach your handler. It does not check the static `Authorization` key. Every delivery is logged with full headers and body, so you can debug verification against real requests.

## Best practices

### Verify the signature on the raw body

Configure a signature key on every webhook and verify `Cko-Signature` against the raw request bytes, with hex encoding and a timing-safe comparison. Parsed-then-reserialized JSON is the most common cause of verification failures.

### Acknowledge fast, process asynchronously

Checkout.com expects a response within 10 seconds. Return 200 as soon as the signature checks out and hand the event to a queue or background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Deduplicate on the event id

Delivery is at least once, and without a signed timestamp the event `id` is your only replay protection. Record processed `evt_...` ids so a retried `payment_captured` never becomes a double fulfillment. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't depend on arrival order

Write handlers that are correct regardless of which event for a payment arrives first, and read the timestamp as `created_on ?? timestamp`, since the field name varies between event types.

### Handle amounts in minor units

`amount` is in the minor currency unit: `20` with `USD` is $0.20. Use each currency's exponent rather than a hardcoded division by 100 if you accept more than one currency.

## Conclusion

Checkout.com webhooks cover the full payment lifecycle (authorizations, captures, refunds, disputes, fraud and 3DS) plus issuing, identity and settlement events, all in a consistent envelope keyed by a snake_case `type`. Verification is a plain HMAC-SHA256 of the raw body in `Cko-Signature`, which keeps the implementation simple but leaves replay protection and ordering to your handler.

Delivery is at least once and unordered, within a 10-second budget and 8 retries, so your endpoint should acknowledge fast, deduplicate on the event `id`, and upsert state to keep payments and orders in sync. [Hookdeck Event Gateway](/event-gateway) puts signature verification, deduplication, and a durable queue with replay in front of your endpoint, so your handlers only ever process trustworthy events.

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