Webhooks
Recibe los eventos de tu tienda en tu servidor, verifica su firma Standard Webhooks y procésalos sin duplicados.
- Configura tu
webhook_urlal empezar y guarda el secretowhsec_…. - Verifica la firma sobre el cuerpo crudo y responde
2xxen menos de 10 segundos. - Procesa cada evento una sola vez, por su
id.
Este endpoint verifica la firma y responde enseguida, sin dependencias externas:
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const secret = process.env.N1CO_WEBHOOK_SECRET; // whsec_…
function verify(headers, body) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest();
return signatures.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const given = Buffer.from(signature, 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/n1co') return res.writeHead(404).end();
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks);
if (!verify(req.headers, body)) return res.writeHead(400).end();
const event = JSON.parse(body);
// Guarda event.id para descartar repetidos y procesa el evento en segundo plano.
console.log(event.type, event.data.object.id);
res.writeHead(204).end();
}).listen(3000);.NET
using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = builder.Configuration["N1co:WebhookSecret"]!; // whsec_…
app.MapPost("/n1co", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray();
if (!Verify(request.Headers, body, secret)) return Results.BadRequest();
// Guarda el webhook-id para descartar repetidos y procesa el evento en segundo plano.
return Results.NoContent();
});
app.Run();
static bool Verify(IHeaderDictionary headers, byte[] body, string secret)
{
string id = headers["webhook-id"].ToString();
string timestamp = headers["webhook-timestamp"].ToString();
string signatures = headers["webhook-signature"].ToString();
if (id.Length == 0 || !long.TryParse(timestamp, out var seconds)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 5 * 60) return false;
var key = Convert.FromBase64String(secret["whsec_".Length..]);
var signed = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(body).ToArray();
var expected = HMACSHA256.HashData(key, signed);
foreach (var entry in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split(',', 2);
if (parts.Length != 2 || parts[0] != "v1") continue;
byte[] given;
try { given = Convert.FromBase64String(parts[1]); }
catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(given, expected)) return true;
}
return false;
}Tipos de evento#
| Tipo | Cuándo se envía | data.object |
|---|---|---|
card.created |
Una captura guardó una tarjeta. | La tarjeta, con card_session y sin el nombre del titular. |
payment_intent.succeeded |
n1co aceptó el cargo. Una vez. | El payment intent. |
payment_intent.payment_failed |
Falló un intento de pago. | El payment intent, con last_error. |
payment_intent.canceled |
El intent se canceló o venció. | El payment intent, con cancellation_reason. |
refund.succeeded |
n1co devolvió el monto de un reembolso. Una vez. | El reembolso. |
refund.failed |
n1co rechazó un envío de un reembolso. | El reembolso, con last_error. |
webhook.test |
Llamaste a POST /v1/webhooks/test. |
{ "message": "test" } |
Los webhooks no llevan datos personales. Cada tipo está en la referencia del API.
El sobre y los encabezados#
{
"id": "evt_UGF5bWVudFN1Y2NlZWRlZDAx",
"type": "payment_intent.succeeded",
"created_at": "2026-10-17T15:07:12Z",
"data": {
"object": {
"id": "pi_UGF5bWVudEludGVudEV4YW1w",
"object": "payment_intent",
"status": "succeeded",
"amount": 1250,
"currency": "USD",
"reference": "order-8812",
"card": "card_Q2FyZEV4YW1wbGUwMDAwMDAx",
"payment_method": { "brand": "visa", "last4": "2701", "exp_month": 12, "exp_year": 2030, "saved": true },
"authorization_code": "159992",
"order_id": "54214",
"refund": null,
"succeeded_at": "2026-10-17T15:07:12Z"
}
}
}El ejemplo omite campos del intent. El cuerpo es idéntico, byte por byte, en cada reintento.
| Encabezado | Valor |
|---|---|
webhook-id |
El id del evento; el mismo en cada reintento. |
webhook-timestamp |
Segundos Unix del intento. |
webhook-signature |
v1, y la firma HMAC-SHA256 en base64. |
n1co-event-type |
El tipo de evento. |
Verificar la firma#
Las entregas siguen Standard Webhooks: cualquier biblioteca compatible acepta tu secreto whsec_… tal como está. Sin biblioteca:
- Quita
whsec_del secreto y decodifica el resto en base64: esa es la clave. - Firma
webhook-id, un punto,webhook-timestamp, un punto y el cuerpo crudo con HMAC-SHA256. - Acepta si alguna entrada
v1,<firma>coincide en tiempo constante y el timestamp está a 5 minutos o menos.
Al rotar el secreto o cambiar la webhook_url en la configuración, las entregas se firman solo con el secreto nuevo.
Reintentos y duplicados#
- Cualquier respuesta que no sea
2xxen 10 segundos cuenta como fallo. Las redirecciones no se siguen. - Los fallos se reintentan a 1 min, 5 min, 30 min, 2 h, 6 h y 24 h. Tras el séptimo intento, la entrega queda
failed. - La entrega es al menos una vez y sin orden: descarta los
idrepetidos y decide con elstatusdel objeto. - Entregar el mismo pedido dos veces debe ser imposible en tu sistema.
Probar y revisar entregas#
POST /v1/webhooks/test entrega un webhook.test firmado, hasta 10 por minuto:
curl -s -X POST https://api-epay.dev.n1co.dev/v1/webhooks/test \
-H "Authorization: Bearer $N1CO_SECRET_KEY"GET /v1/events y GET /v1/events/{id} devuelven los eventos con su estado de entrega en delivery:
curl -s 'https://api-epay.dev.n1co.dev/v1/events?type=payment_intent.succeeded&limit=50' \
-H "Authorization: Bearer $N1CO_SECRET_KEY"Los campos de delivery y los filtros están en la referencia del API.