n1coePayDocs Referencia del API

Webhooks

Recibe los eventos de tu tienda en tu servidor, verifica su firma Standard Webhooks y procésalos sin duplicados.

  1. Configura tu webhook_url al empezar y guarda el secreto whsec_….
  2. Verifica la firma sobre el cuerpo crudo y responde 2xx en menos de 10 segundos.
  3. Procesa cada evento una sola vez, por su id.

Este endpoint verifica la firma y responde enseguida, sin dependencias externas:

Node

webhooks.mjs
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

Program.cs
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:

  1. Quita whsec_ del secreto y decodifica el resto en base64: esa es la clave.
  2. Firma webhook-id, un punto, webhook-timestamp, un punto y el cuerpo crudo con HMAC-SHA256.
  3. 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 2xx en 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 id repetidos y decide con el status del 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.