Webhooks
Gestionar webhooks
Registra una dirección https pública que recibe un evento firmado por llamada cuando cambia un pedido o una reposición. Los endpoints pertenecen a la cuenta y cada uno tiene su propio secreto.
https://notpanel.com/api/v3action=webhook.add- Clave de API requerida
- Límite de solicitudes superado
- Cuerpo:
application/x-www-form-urlencoded
Parámetros
Siempre se envía
keystringObligatorio- Tu clave de API.
actionstringObligatoriowebhook.addDebe ser la cadena literal "webhook.add".
Parámetros de la acción
urlstring (URL)Obligatorio- Dirección https pública que recibe los eventos.
eventsstring (CSV)Opcional- Tipos de evento separados por comas a los que suscribirse, de 8. Omítelo para recibirlos todos.order.processing, order.in_progress, order.completed, order.partial, order.canceled, order.cancel_refused, refill.completed, refill.rejected
curl -X POST https://notpanel.com/api/v3 \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "key=YOUR_API_KEY&action=webhook.add&url=https://your-server.example.com/notpanel-webhook&events=order.completed,order.partial,order.canceled"const res = await fetch("https://notpanel.com/api/v3", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
key: "YOUR_API_KEY",
action: "webhook.add",
url: "https://your-server.example.com/notpanel-webhook",
events: "order.completed,order.partial,order.canceled",
}),
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
"https://notpanel.com/api/v3",
data={
"key": "YOUR_API_KEY",
"action": "webhook.add",
"url": "https://your-server.example.com/notpanel-webhook",
"events": "order.completed,order.partial,order.canceled",
},
)
print(res.json())<?php
$body = http_build_query([
'key' => 'YOUR_API_KEY',
'action' => 'webhook.add',
'url' => 'https://your-server.example.com/notpanel-webhook',
'events' => 'order.completed,order.partial,order.canceled',
]);
$response = file_get_contents('https://notpanel.com/api/v3', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $body,
'ignore_errors' => true,
],
]));
print_r(json_decode($response, true));
Sustituye YOUR_API_KEY por la clave de tu página de API.
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"url": "https://your-server.example.com/notpanel-webhook",
"events": ["order.completed", "order.partial", "order.canceled"],
"active": true,
"paused_at": null,
"created_at": "2026-10-03T09:00:00Z",
"status": "active",
"failureCount": 0,
"lastDeliveredAt": null,
"createdAt": "2026-10-03T09:00:00.000Z",
"secret": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"webhook_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
}Errores
- 400
{"error":"Invalid webhook URL","error_code":"INVALID_WEBHOOK_URL","retryable":false}La dirección no es una dirección https pública, o apunta a una privada.
- 400
{"error":"Invalid events: not_allowed","error_code":"INVALID_PARAMETER","retryable":false,"details":{"param":"events","rule":"not_allowed"}}Un nombre de evento no está en la lista. Revisa la ortografía: los nombres son como
order.completed. - 409
{"error":"Maximum 5 webhook endpoints per account","error_code":"WEBHOOK_LIMIT","retryable":false,"details":{"limit":5}}La cuenta ya tiene el máximo de 5 endpoints. Elimina uno primero.
Eventos
Suscríbete a cualquiera de ellos. Cada uno se envía como su propia llamada cuando se guarda el cambio, y un cambio que se revierte no envía nada. Un ping solo lo envía webhook.test.
Tipos de evento
order.processing- El pedido se envió y está esperando a que empiece la entrega.
order.in_progress- La entrega ha comenzado.
order.completed- El pedido se entregó por completo.
order.partial- Se entregó una parte del pedido y el resto se reembolsó.
order.canceled- El pedido se canceló y se reembolsó. Sustituye al anterior
order.refunded. order.cancel_refused- Se rechazó una solicitud de cancelación. El pedido sigue en curso.
refill.completed- Se realizó una reposición. Sustituye al anterior
order.refill_completed. refill.rejected- Se rechazó una reposición. Los datos incluyen el motivo.
ping- Un evento de prueba, enviado solo por
webhook.test.
Listar endpoints
action=webhook.list devuelve todos los endpoints de la cuenta con sus eventos, si está activo y cuándo se pausó, si se pausó. El secreto nunca se devuelve. También se incluyen las claves anteriores status, failureCount, lastDeliveredAt y createdAt.
https://notpanel.com/api/v3action=webhook.listkey=YOUR_API_KEY&action=webhook.list[
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"url": "https://your-server.example.com/notpanel-webhook",
"events": ["order.completed", "order.partial", "order.canceled"],
"active": true,
"paused_at": null,
"created_at": "2026-10-03T09:00:00Z",
"status": "active",
"failureCount": 0,
"lastDeliveredAt": null,
"createdAt": "2026-10-03T09:00:00.000Z"
}
]Enviar un evento de prueba
action=webhook.test pone en cola un ping hacia el endpoint. Úsalo para comprobar tu receptor y tu código de firma. La respuesta contiene el ID de la entrega.
https://notpanel.com/api/v3action=webhook.testkey=YOUR_API_KEY&action=webhook.test&id=3f2504e0-4f89-41d3-9a0c-0305e82c3301{
"queued": true,
"delivery_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}Eliminar un endpoint
action=webhook.remove elimina el endpoint y su secreto. Una entrega que ya está en camino aún puede llegar; no se pone nada nuevo en cola para él.
https://notpanel.com/api/v3action=webhook.removekey=YOUR_API_KEY&action=webhook.remove&id=3f2504e0-4f89-41d3-9a0c-0305e82c3301{
"removed": true,
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"success": true
}Contenido de la entrega
Cada entrega es un evento en un POST con Content-Type: application/json. El cuerpo es un sobre, y data contiene el pedido o la reposición con el mismo formato que status y refill_status. Esta es la versión de esquema 1.
Sobre
idstring- ID único del evento. Úsalo para ignorar duplicados.
typestring- El tipo de evento.
schema_versioninteger- Versión de este formato. Ahora es 1.
created_atstring- Cuándo se creó el evento.
sequenceinteger- Aumenta con cada evento de la cuenta, para que puedas ordenar los eventos.
dataobjectorderorefill, con el formato de la acción de lectura correspondiente. Vacío para un ping.
Cabeceras de la solicitud
X-Webhook-Signature- HMAC-SHA256 de la marca de tiempo, un punto y el cuerpo exacto, escrito como
sha256=seguido de los dígitos hexadecimales. X-Webhook-Timestamp- Hora Unix en segundos de este intento. Un reintento recibe una nueva marca de tiempo y firma.
X-Webhook-Event- El tipo de evento, igual que
typeen el cuerpo. X-Webhook-Delivery-Id- ID de esta entrega, el mismo en cada reintento.
{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e51",
"type": "order.completed",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1042,
"data": {
"order": {
"charge": "1.20",
"start_count": "3572",
"status": "Completed",
"remains": "0",
"currency": "USD",
"order": 1234567,
"status_key": "completed",
"refund": "0.00",
"reason_code": null,
"reason": null,
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": false,
"refill_available_at": "2026-10-03T18:35:00Z",
"updated_at": "2026-10-03T12:35:00Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e52",
"type": "order.partial",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1043,
"data": {
"order": {
"charge": "0.87",
"start_count": "3572",
"status": "Partial",
"remains": "157",
"currency": "USD",
"order": 1234568,
"status_key": "partial",
"refund": "0.13",
"reason_code": "partially_delivered",
"reason": "Part of the order was delivered. The rest was refunded.",
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": true,
"refill_available_at": null,
"updated_at": "2026-10-03T12:00:00Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e53",
"type": "order.canceled",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1044,
"data": {
"order": {
"charge": "0.00",
"start_count": "0",
"status": "Canceled",
"remains": "1000",
"currency": "USD",
"order": 1234569,
"status_key": "cancelled",
"refund": "1.20",
"reason_code": "customer_canceled",
"reason": "You canceled this order. The charge was refunded.",
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": false,
"refill_available_at": null,
"updated_at": "2026-10-03T10:00:09Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e56",
"type": "refill.rejected",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1047,
"data": {
"refill": {
"refill": 5502,
"status": "Rejected",
"status_key": "rejected",
"order": 1234568,
"reason_code": "refill_too_early",
"reason": "This order cannot be refilled yet. Try again later.",
"created_at": "2026-10-03T12:40:00Z",
"updated_at": "2026-10-03T12:41:00Z"
}
}
}Verificación de firmas
Lee X-Webhook-Timestamp y los bytes exactos del cuerpo. Calcula HMAC-SHA256 sobre <timestamp>.<body bytes> con el secreto del endpoint, escríbelo como sha256=<hex> y compáralo con X-Webhook-Signature en tiempo constante. Un reintento tiene el mismo ID de evento pero una nueva marca de tiempo y firma.
Lee el cuerpo sin procesar y las cabeceras
Calcula y compara la firma
Omite los eventos que ya procesaste
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {{rawBody: unknown, signatureHeader: unknown, timestampHeader: unknown,
* secret: unknown, nowSeconds?: number, toleranceSeconds?: number}} input
*/
function verifyNotPanelBody({
rawBody,
signatureHeader,
timestampHeader,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
toleranceSeconds = 300, // Example receiver policy, not a NotPanel rule.
}) {
if (!Buffer.isBuffer(rawBody)) return false;
if (typeof secret !== "string" || secret.length === 0) return false;
if (typeof signatureHeader !== "string") return false;
if (typeof timestampHeader !== "string") return false;
if (!/^(0|[1-9]\d{0,15})$/.test(timestampHeader)) return false;
if (!Number.isSafeInteger(nowSeconds)) return false;
if (!Number.isSafeInteger(toleranceSeconds) || toleranceSeconds < 0) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader);
if (!match?.[1]) return false;
const expected = createHmac("sha256", secret)
.update(timestampHeader, "utf8")
.update(".", "utf8")
.update(rawBody)
.digest();
const received = Buffer.from(match[1], "hex");
return timingSafeEqual(expected, received);
}Un ejemplo resuelto
Úsalo para probar tu código. La firma cubre la marca de tiempo, un punto y el cuerpo exactamente como se muestra, sin espacios ni saltos de línea adicionales.
secret = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
timestamp = 1790000000
body = {"id":"e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e51","type":"order.completed","schema_version":1,"created_at":"2026-10-03T12:30:00Z","sequence":1042,"data":{"order":{"charge":"1.20","start_count":"3572","status":"Completed","remains":"0","currency":"USD","order":1234567,"status_key":"completed","refund":"0.00","reason_code":null,"reason":null,"can_cancel":false,"cancel_available":false,"cancel_state":null,"can_refill":false,"refill_available_at":"2026-10-03T18:35:00Z","updated_at":"2026-10-03T12:35:00Z"}}}X-Webhook-Signature: sha256=6e8e90578f566d2e14b5c3914eba7e378c4107a5a4d4ddd52c747791f2264fffEntrega y reintentos
Al menos una vez
Cualquier respuesta 2xx dentro de 10 segundos cuenta como entregada. No se siguen las redirecciones y solo se leen los primeros 4096 bytes de tu respuesta. El mismo evento puede llegar más de una vez, así que ignora un ID de evento que ya hayas procesado.
Ocho intentos a lo largo de un día
8 intentos en total: el primero de inmediato y después 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h tras el evento. Cada intento conserva el mismo ID de evento y de entrega.
Los endpoints se pausan solos
Tras 50 fallos seguidos, o 3 días sin un éxito, el endpoint se pausa y se avisa al propietario de la cuenta. Arregla tu receptor y vuelve a añadir el endpoint.
El orden no está garantizado
Los eventos pueden llegar desordenados. Usa
sequencepara ordenarlos, procesa cada ID de evento una sola vez y leeaction=statuscuando necesites el estado actual.