n1coePayDocs API reference

Webhooks

Receive your store's events on your server, verify their Standard Webhooks signature and process them without duplicates.

  1. Set your webhook_url when you get started and store the whsec_… secret.
  2. Verify the signature over the raw body and respond 2xx within 10 seconds.
  3. Process each event only once, by its id.

This endpoint verifies the signature and responds right away, with no external dependencies:

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);
  // Store event.id to discard duplicates and process the event in the background.
  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();

    // Store the webhook-id to discard duplicates and process the event in the background.
    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;
}

Event types#

Type When it is sent data.object
card.created A capture saved a card. The card, with card_session and without the cardholder name.
payment_intent.succeeded n1co accepted the charge. Once. The payment intent.
payment_intent.payment_failed A payment attempt failed. The payment intent, with last_error.
payment_intent.canceled The intent was canceled or expired. The payment intent, with cancellation_reason.
refund.succeeded n1co returned the amount of a refund. Once. The refund.
refund.failed n1co refused a send of a refund. The refund, with last_error.
webhook.test You called POST /v1/webhooks/test. { "message": "test" }

Webhooks carry no personal data. Each type is in the API reference.

The envelope and the headers#

{
  "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"
    }
  }
}

The example leaves out some intent fields. The body is identical, byte for byte, on every retry.

Header Value
webhook-id The event id; the same on every retry.
webhook-timestamp Unix seconds of the attempt.
webhook-signature v1, and the base64 HMAC-SHA256 signature.
n1co-event-type The event type.

Verify the signature#

Deliveries follow Standard Webhooks: any compatible library accepts your whsec_… secret as is. Without a library:

  1. Remove whsec_ from the secret and base64-decode the rest: that is the key.
  2. Sign webhook-id, a dot, webhook-timestamp, a dot and the raw body with HMAC-SHA256.
  3. Accept if any v1,<signature> entry matches in constant time and the timestamp is within 5 minutes.

When you rotate the secret or change the webhook_url in the setup, deliveries are signed only with the new secret.

Retries and duplicates#

  • Any response other than 2xx within 10 seconds counts as a failure. Redirects are not followed.
  • Failures are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. After the seventh attempt, the delivery is failed.
  • Delivery is at least once and unordered: discard repeated id values and decide with the object's status.
  • Fulfilling the same order twice must be impossible in your system.

Test and review deliveries#

POST /v1/webhooks/test delivers a signed webhook.test, up to 10 per minute:

curl -s -X POST https://api-epay.dev.n1co.dev/v1/webhooks/test \
  -H "Authorization: Bearer $N1CO_SECRET_KEY"

GET /v1/events and GET /v1/events/{id} return the events with their delivery state in delivery:

curl -s 'https://api-epay.dev.n1co.dev/v1/events?type=payment_intent.succeeded&limit=50' \
  -H "Authorization: Bearer $N1CO_SECRET_KEY"

The delivery fields and the filters are in the API reference.