VALUE AL

← All writing

Integrating a payment provider is two signing problems, not one

Every payment integration starts the same way. You read the provider's docs, you find the "how to sign requests" page, you copy the snippet, the sandbox goes green, and you ship. A week later a webhook silently fails in production — or worse, a malformed one slips through — and you spend an afternoon discovering that "signing" was never one problem. It was two.

You sign outbound requests with your key. You verify inbound webhooks with theirs. Those two directions usually use different secrets and different canonical forms. Treat them as one and you've built a bug that the sandbox can't catch.

I've wired up enough providers to know where the edges are. This is the short version of what I wish every integration guide said up front.

The two directions

A payment integration has two signed channels going in opposite directions:

These feel symmetric. They are not. They typically differ in which secret signs them and in what exactly gets signed. Almost every integration mistake I've seen lives in conflating the two.

Gotcha 1 — the webhook secret is not your API secret

Most providers issue you (at least) two secrets: an API key for authenticating outbound calls, and a separate webhook signing secret for verifying inbound callbacks. They look interchangeable. They are not. Using the API key to verify webhooks "works" right up until you rotate one of them, and then only half your system breaks — the confusing kind of outage.

Model them as two distinct secrets from day one, even if a provider happens to reuse one. It costs nothing and saves a rotation incident later.

Gotcha 2 — the two directions canonicalize differently

The canonical form is the exact byte string you run the HMAC (or signature) over. Providers rarely sign "the body." They sign some agreed construction of the request:

Here's the trap: the outbound canonical form and the inbound one are frequently different. If you write a single Sign() helper and reuse it for verification, you'll match one direction and silently mismatch the other. Split them. One canonical form for what you send, another for what you verify — each matching that provider's spec exactly, down to the separators.

A stray newline or a . in the wrong place changes the whole HMAC. There is no partial credit.

Gotcha 3 — verify the bytes you received, not the ones you re-serialized

This one is subtle and bites hard. The provider signed the exact bytes of the webhook body. If your framework parses that JSON into an object and you re-serialize it to verify, you may produce different bytes — reordered keys, different whitespace, a different number format, a stripped BOM — and verification fails for a perfectly genuine webhook. Worse, lenient re-serialization can let a tampered body verify.

Rule: capture the raw request body before anything deserializes it, and verify over those bytes. In ASP.NET Core that means reading the body stream yourself, not binding a model.

using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
byte[] rawBody = ms.ToArray(); // the exact bytes the provider signed

Gotcha 4 — compare signatures in constant time

When you compare the expected signature to the received one, a == b is a timing oracle: it returns faster the earlier the first differing byte is, which leaks information about the correct value to an attacker who can measure it. Use a constant-time comparison (CryptographicOperations.FixedTimeEquals on modern .NET). It's a one-line change that turns a theoretical attack into a non-issue.

Gotcha 5 — timestamps and replay

A valid signature means the message is authentic. It does not mean it's fresh. Without a freshness check, anyone who captures one signed webhook can replay it forever. If the provider includes a timestamp in the signed payload, check that it's within a tolerance of now (a few minutes), and reject stale ones. Pick the tolerance deliberately — too tight and clock skew causes false rejects, too loose and replay protection is theatre.

HMAC vs public-key signatures (and the ECDSA format trap)

Many providers use HMAC — a symmetric MAC where the same secret signs and verifies. Plenty of others sign with a private key and publish a public key you verify against: RSA (PKCS#1 v1.5 or PSS) or ECDSA. The mental model shifts: with HMAC you share one secret; with public-key schemes you hold only their public key and can't forge anything even if you wanted to.

And the classic ECDSA footgun: the (r, s) signature pair has two common encodings — ASN.1 DER (classic/REST APIs) and IEEE P1363 (raw fixed-length r‖s, used by JOSE/JWT, e.g. ES256). They are not interchangeable; feed one where the other is expected and verification just fails with no useful error. Always match the provider's format.

A pre-flight checklist

Before you call an integration "done":

How I package this

I got tired of re-implementing these same five mistakes, so I extracted the careful version into a small, dependency-free .NET library: Countersign. It keeps the two directions explicitly separate — RequestSigner for outbound, WebhookVerifier for inbound — supports HMAC / RSA / ECDSA (with both ECDSA formats), does the constant-time compare and replay tolerance for you, and signs the exact bytes.

var verifier = new WebhookVerifier(
    webhookSecret,                       // their webhook secret, not your API key
    CanonicalForms.TimestampDotBody,     // match the provider's signed form
    tolerance: TimeSpan.FromMinutes(5)); // reject stale/replayed

var result = verifier.Verify(
    new SignatureContext(rawBody, timestamp: headerTimestamp),
    headerSignature,
    DateTimeOffset.FromUnixTimeSeconds(long.Parse(headerTimestamp)));

if (result != VerificationResult.Valid) return Results.Unauthorized();

It's MIT-licensed and on GitHub. If it saves you one afternoon of "but it works in the sandbox," it did its job.


Countersign is built by Value.al — a software studio doing the careful kind of payments and backend work. If you're wiring up a provider and want a second pair of eyes, say hello.