Webhooks
Receive your store's events on your server, verify their Standard Webhooks signature and process them without duplicates.
- Set your
webhook_urlwhen you get started and store thewhsec_…secret. - Verify the signature over the raw body and respond
2xxwithin 10 seconds. - Process each event only once, by its
id.
This endpoint verifies the signature and responds right away, with no external dependencies:
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);
// 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
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:
- Remove
whsec_from the secret and base64-decode the rest: that is the key. - Sign
webhook-id, a dot,webhook-timestamp, a dot and the raw body with HMAC-SHA256. - 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
2xxwithin 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
idvalues and decide with the object'sstatus. - 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.