Automatizar la entrega de licencias con webhooks de fulfillment
Un pedido pagado debería generar una licencia sin ningún humano de por medio. Así funcionan los webhooks de fulfillment de principio a fin: verificar la firma sobre el cuerpo en bruto, hacer la entrega idempotente por pedido, y mapear los eventos de pago, reembolso y cancelación a emitir, revocar y renovar.
Vender software no debería requerir que un humano copie una clave de licencia en un correo tras cada venta. Cuando un cliente paga, una licencia debería aparecer en su bandeja de entrada segundos después, y cuando reembolsa, debería dejar de funcionar, todo automáticamente. El mecanismo que lo logra es el webhook de fulfillment: una llamada de tu proveedor de pagos que tu sistema de licenciamiento convierte en una emisión, renovación o revocación. Bien hecho, es fiable e invisible. Hecho con descuido, es una vía para que cualquiera genere licencias gratis. La diferencia son tres detalles.
El flujo
Un cliente completa el checkout con tu proveedor de pagos. El proveedor registra el pedido y envía un POST HTTP a la URL del webhook que configuraste, con un cuerpo JSON que describe qué ocurrió y una cabecera de firma. Tu manejador verifica la firma, lee el evento y actúa: un pedido pagado genera una licencia y envía la clave por correo; un reembolso o cancelación la revoca. El proveedor espera una respuesta 2xx; si no la obtiene, reintenta.
Keyright expone exactamente este endpoint —POST /webhooks/fulfill/{tenant}— y un adaptador fino normaliza la carga de cada proveedor en una sola forma, de modo que la lógica de licenciamiento no le importa si la venta vino de Stripe, Paddle, Lemon Squeezy o tu propia facturación. El procesamiento del pago se queda con el proveedor; el webhook solo impulsa el licenciamiento.
Detalle 1 — verifica la firma sobre el cuerpo en bruto
La URL del webhook es pública. Si tu manejador confía en lo que recibe, un atacante que adivine la URL puede hacer POST de {"type":"paid", ...} y emitirse una licencia. La defensa es un secreto compartido: el proveedor calcula un HMAC del cuerpo en bruto de la solicitud con un secreto que solo tú y él conocen, y lo envía en una cabecera (Keyright usa X-Keyright-Signature). Tú lo recalculas y lo comparas.
El matiz que hace tropezar a la gente: verifica sobre los bytes exactos en bruto que recibiste, antes de deserializar. Si analizas el JSON y lo vuelves a serializar para calcular el hash, los espacios y el orden de las claves cambian y la firma nunca coincidirá.
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
});
Dos cosas que vale la pena copiar: usa FixedTimeEquals en vez de == para que la comparación no filtre el secreto por temporización, y mantén el secreto por tenant para que un secreto filtrado no pueda falsificar pedidos de todos.
Detalle 2 — hazlo idempotente
Las redes fallan y los proveedores reintentan. El mismo evento «pedido pagado» llegará, tarde o temprano, dos veces: un timeout de tu lado, un fallo del suyo, un reenvío manual desde su panel. Si cada entrega genera una licencia, una venta se convierte en tres claves.
La solución es hacer el fulfillment idempotente, con clave en un identificador estable que el proveedor incluye en cada reintento: la referencia del pedido o la suscripción. Emite la licencia etiquetada con esa referencia y búscala primero:
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 hace esto internamente —la emisión es idempotente por referencia de pedido— pero el principio se mantiene donde sea que lo construyas: un manejador de webhook debe ser seguro de llamar repetidamente con la misma entrada y producir el mismo resultado.
Detalle 3 — mapea eventos a acciones de licenciamiento
Un proveedor de pagos emite más que «pagado». Decide una vez qué significa cada evento para una licencia, y mantén el mapeo pequeño y explícito:
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
};
Una renovación extiende la licencia existente en vez de generar una clave nueva: el cliente conserva la clave que ya embebió. Un reembolso o contracargo la revoca, lo que (como se cubrió en entradas anteriores) la detiene en la siguiente comprobación en línea y, para clientes sin conexión, mediante una lista de revocación firmada. Un pago fallido es donde eliges la política: detenerte de inmediato, o marcar como moroso y permitir una ventana de gracia antes de revocar.
Responde rápido, falla seguro
Dos hábitos operativos mantienen sana la integración. Primero, responde rápido: haz la verificación de firma y la emisión de forma síncrona si son rápidas, pero si algún paso es lento, confirma con un 2xx y termina el trabajo en segundo plano; un proveedor que espera demasiado marca la entrega como fallida y reintenta, amplificando la carga. Segundo, trata una firma ausente o inválida como un rechazo duro (401), nunca como un éxito silencioso; y registra cada entrega rechazada, porque una ráfaga de ellas es o bien un secreto mal configurado o bien alguien sondeando tu endpoint.
Conecta estos tres detalles —verifica sobre el cuerpo en bruto, deduplica por referencia de pedido, mapea eventos explícitamente— y la entrega de licencias se vuelve algo en lo que nunca vuelves a pensar: un cliente paga y su clave lo está esperando antes de que cambie de pestaña.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.