Integration guide

Checkout.com webhooks: Cko-Signature in ASP.NET Core

How Checkout.com signs webhooks, why the raw body matters in .NET, and how to verify Cko-Signature without breaking model binding.

Runnable sample code

Checkout.com signs webhooks with HMAC-SHA256 and puts the digest in a Cko-Signature header. The algorithm is simple. The part that burns hours in ASP.NET Core is not.

This guide covers what Checkout.com actually sends, how to verify it against the raw request bytes, and the operational rules that sit next to the signature — because a valid signature is not enough to process an event safely.

Every verifier here is from a repository that builds and tests with no credentials and no network. For the general .NET traps that every provider hits, see Validating webhook signatures in ASP.NET Core.

What Checkout.com sends

Two optional security layers can be configured on a webhook workflow:

  1. An Authorization header value you choose, so you can reject requests that lack a shared secret before doing any crypto.
  2. A signature key, used to HMAC the payload. Checkout.com puts the result in Cko-Signature as hex-encoded (Base16) HMAC-SHA256.

The signature covers the raw body only. There is no timestamp inside the HMAC, and no sha256= algorithm prefix. That puts Checkout.com closer to GitHub than to Stripe:

Provider Header Signed input Encoding
Checkout.com Cko-Signature Raw body Lowercase hex
Stripe Stripe-Signature timestamp.body Hex, with scheme version
GitHub X-Hub-Signature-256 Raw body sha256= + hex

No timestamp means the signature never expires. A captured delivery stays cryptographically valid. Replay defence has to live in your handler — deduplicate on the event id — not in the HMAC.

The verification rule, from the docs

Checkout.com’s own guidance is:

  1. Hash the payload with HMAC-SHA256, using your webhook signature key as the key.
  2. Compare the result with Cko-Signature.
  3. Do that calculation on the raw HTTP body, not on a re-serialised model.

They call out special characters explicitly — ©, ®, ™ — because parse-and-rewrite cycles change those bytes and break verification while looking exactly like a wrong secret.

Their support article on verification failures lists the same three causes you will hit in practice: wrong environment/key, middleware mutating the body, and an error in the signature logic.

The .NET implementation

Read raw bytes first. Verify. Deserialise afterwards.

public static class CkoSignature
{
    public const string HeaderName = "Cko-Signature";

    public enum Result
    {
        Valid,
        MissingHeader,
        SignatureMismatch,
    }

    public static Result Verify(string? header, ReadOnlySpan<byte> body, string signatureKey)
    {
        if (string.IsNullOrWhiteSpace(header) || string.IsNullOrEmpty(signatureKey))
            return Result.MissingHeader;

        var expected = Sign(body, signatureKey);

        return SignatureComparer.FixedTimeEquals(expected, header.Trim())
            ? Result.Valid
            : Result.SignatureMismatch;
    }

    public static string Sign(ReadOnlySpan<byte> body, string signatureKey) =>
        SignatureComparer.ToHex(SignatureComparer.HmacSha256(signatureKey, body));
}

SignatureComparer.FixedTimeEquals hashes both sides before comparing, so a wrong-length digest does not leak length through early return. Ordinary == is still a vulnerability here, even though the header is “just hex”.

A minimal endpoint shape:

[HttpPost("/webhooks/checkout")]
public async Task<IActionResult> Receive(CancellationToken ct)
{
    var body = await RawBody.ReadAsync(Request, ct);
    var signature = Request.Headers[CkoSignature.HeaderName].ToString();

    if (CkoSignature.Verify(signature, body, _signatureKey) != CkoSignature.Result.Valid)
        return Unauthorized();

    // Optional: also check the Authorization header you configured on the workflow.
    // Deserialise `body` only after verification succeeds.
    // Enqueue work; acknowledge fast.

    return Ok();
}

RawBody.ReadAsync enables buffering, copies the stream, and rewinds so later deserialisation still works. Without that, model binding eats the body and your HMAC runs over an empty payload.

Acknowledge within ten seconds

Checkout.com requires the webhook server to acknowledge every delivery within 10 seconds. Miss that window and the attempt times out and fails.

Return 2xx as soon as the delivery is authenticated and durably accepted for processing. Do fulfilment, ledger writes and third-party calls out of band. A handler that waits on downstream work will look healthy in local testing and fail under load.

Retries are aggressive and order is not guaranteed

If an attempt fails, Checkout.com resends up to eight times, at roughly:

5 minutes → 10 → 15 → 30 → 1 hour → 4 hours → 12 hours → 12 hours

When an attempt succeeds, later automatic retries are cancelled. If all eight fail, the webhook is cancelled (you can still resend from the Dashboard or API).

Separately: delivery is at least once, and sequence is not guaranteed. You may see payment_captured before payment_approved, or the same event more than once.

That combination forces two handler rules:

  1. Do not rely on webhook order to drive state transitions.
  2. Deduplicate on the event id (or an equivalent stable identifier) before side effects. Verification proves authenticity; it does not prove novelty.

Failure modes

Symptom Likely cause Fix
Signature never matches, key is correct Verifying a deserialised / re-serialised body Read raw bytes before binding
Works in sandbox, fails in production Using the other environment’s signature key Keep sandbox and live keys separate
Intermittent mismatches under a proxy Body or header rewritten in transit Verify at the edge; inspect middleware
Duplicate orders / double fulfilment Treating verified as new Deduplicate on event id
Dashboard shows timeouts Handler exceeds 10 seconds Ack first, process later
Events appear “out of order” Expected behaviour Drive state from event type + ids, not arrival order

Production checklist

  • Generate and store a dedicated webhook signature key per environment
  • Optionally set an Authorization header secret on the workflow and check it
  • Verify against raw bytes via EnableBuffering() / rewind
  • Constant-time compare the hex digests
  • Return 2xx within 10 seconds; process out of band
  • Deduplicate on event id across instances
  • Do not assume delivery order
  • Alert on a rising rate of signature failures

Sources

All checked 2 August 2026.

The sample repository includes CkoSignature alongside the Stripe-style and GitHub-style verifiers, so you can see how the schemes differ in code rather than in prose.