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 codeCheckout.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:
- An Authorization header value you choose, so you can reject requests that lack a shared secret before doing any crypto.
- A signature key, used to HMAC the payload. Checkout.com puts the result in
Cko-Signatureas 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:
- Hash the payload with HMAC-SHA256, using your webhook signature key as the key.
- Compare the result with
Cko-Signature. - 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:
- Do not rely on webhook order to drive state transitions.
- 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
- Receive webhooks
— HMAC overview, hex-encoded
Cko-Signature, retry schedule, sequencing. - Configure your webhook server — Dashboard/API setup, Authorization header, raw-body verification, 10-second acknowledgement, special-character warning.
- Webhook signature verification failures — wrong key / environment, middleware body mutation, signature logic errors.
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.