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:
- Outbound — you call the provider's API. You build a signature over the request and send it in a header. The provider checks it with a key they gave you.
- Inbound — the provider calls you back (webhooks: payment succeeded, refund settled, dispute opened). They sign the callback; you verify it before trusting a single field.
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:
- some sign the raw body only;
- some sign
"{timestamp}.{body}"(the Stripe-style form, which binds the signature to a moment); - outbound request signing often includes the method and path:
"{method}\n{path}\n{timestamp}\n{body}".
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":
- Outbound and inbound use the correct, separate secrets.
- The canonical form for each direction matches the spec exactly (separators included).
- You verify over the raw received bytes, not a re-serialized body.
- Signature comparison is constant-time.
- Webhook timestamps are checked against a sensible tolerance.
- You know whether it's HMAC / RSA / ECDSA — and for ECDSA, DER vs P1363.
- You've tested a tampered payload and a stale one, not just the happy path.
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.