Selling software should not require a human to copy a license key into an email after every sale. When a customer pays, a license should appear in their inbox seconds later, and when they refund, it should stop working — all automatically. The mechanism that makes this happen is the fulfillment webhook: a callback from your payment provider that your licensing system turns into an issue, renew or revoke. Done right, it is reliable and invisible. Done carelessly, it is a way for anyone to mint free licenses. The difference is three details.
The flow
A customer completes checkout with your payment provider. The provider records the order and sends an HTTP POST to the webhook URL you configured, with a JSON body describing what happened and a signature header. Your handler verifies the signature, reads the event, and acts: a paid order mints a license and emails the key; a refund or cancellation revokes it. The provider expects a 2xx response; if it does not get one, it retries.
Keyright exposes exactly this endpoint — POST /webhooks/fulfill/{tenant} — and a thin adapter normalizes each provider's payload into one shape, so the licensing logic does not care whether the sale came from Stripe, Paddle, Lemon Squeezy or your own billing. Payment processing stays with the provider; the webhook only drives licensing.
Detail 1 — verify the signature over the raw body
The webhook URL is public. If your handler trusts whatever it receives, an attacker who guesses the URL can POST {"type":"paid", ...} and issue themselves a license. The defense is a shared secret: the provider computes an HMAC of the raw request body with a secret only you and it know, and sends it in a header (Keyright uses X-Keyright-Signature). You recompute it and compare.
The subtlety that trips people up: verify over the exact raw bytes you received, before deserializing. If you parse the JSON and re-serialize it to hash, whitespace and key ordering change and the signature will never match.
app.MapPost("/webhooks/fulfill/{tenant}", async (HttpContext ctx, string tenant) =>
{
// Read the RAW body first — do not bind to a model yet.
ctx.Request.EnableBuffering();
using var reader = new StreamReader(ctx.Request.Body);
string rawBody = await reader.ReadToEndAsync();
string provided = ctx.Request.Headers["X-Keyright-Signature"];
string expected = Convert.ToHexString(
HMACSHA256.HashData(Secret(tenant), Encoding.UTF8.GetBytes(rawBody))
).ToLowerInvariant();
// Constant-time compare so a timing side channel can't leak the secret.
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(provided ?? ""), Encoding.ASCII.GetBytes(expected)))
return Results.Unauthorized();
var evt = JsonSerializer.Deserialize<FulfillmentEvent>(rawBody)!;
// ... dispatch, below
});
Two things worth copying: use FixedTimeEquals rather than == so the comparison does not leak the secret through timing, and keep the secret per tenant so one leaked secret cannot forge orders for everyone.
Detail 2 — make it idempotent
Networks fail and providers retry. The same "order paid" event will, sooner or later, arrive twice — a timeout on your side, a blip on theirs, a manual replay from their dashboard. If each delivery mints a license, one sale becomes three keys.
The fix is to make fulfillment idempotent, keyed on a stable identifier the provider includes on every retry: the order or subscription reference. Issue the license tagged with that reference, and look it up first:
async Task<License> Fulfill(FulfillmentEvent evt)
{
// Same order reference on every retry → find-or-create, never duplicate.
var existing = await _licenses.FindByOrderRef(evt.OrderRef);
if (existing is not null) return existing;
return await _licenses.Issue(new IssueRequest
{
Product = evt.Product,
Tier = evt.Tier,
Email = evt.CustomerEmail,
OrderRef = evt.OrderRef, // the idempotency key
Seats = evt.Quantity,
});
}
Keyright does this internally — issuance is idempotent per order ref — but the principle holds wherever you build it: a webhook handler must be safe to call repeatedly with identical input and produce the same result.
Detail 3 — map events to licensing actions
A payment provider emits more than "paid." Decide once what each event means for a license, and keep the mapping small and explicit:
Task Handle(FulfillmentEvent evt) => evt.Type switch
{
"paid" or "subscription.renewed" => Fulfill(evt), // issue or extend
"refunded" or "subscription.cancelled" => Revoke(evt.OrderRef), // stop it working
"payment.failed" => FlagPastDue(evt.OrderRef), // optional grace
_ => Task.CompletedTask, // ignore the rest
};
A renewal extends the existing license rather than minting a new key — the customer keeps the key they already embedded. A refund or chargeback revokes it, which (as covered in earlier posts) stops it validating on the next online check and, for offline clients, through a signed revocation list. A failed payment is where you choose policy: hard-stop immediately, or flag past-due and allow a grace window before revoking.
Return fast, fail safe
Two operational habits keep the integration healthy. First, respond quickly: do the signature check and the issue synchronously if they are fast, but if any step is slow, acknowledge with a 2xx and finish the work in the background — a provider that waits too long marks the delivery failed and retries, amplifying load. Second, treat a missing or invalid signature as a hard reject (401), never a silent success; and log every rejected delivery, because a burst of them is either a misconfigured secret or someone probing your endpoint.
Wire these three details — verify over the raw body, deduplicate by order reference, map events explicitly — and license delivery becomes something you never think about again: a customer pays, and their key is waiting for them before they switch tabs.
Top comments (0)