Webhooks
Gérer les webhooks
Enregistre une adresse https publique qui reçoit un événement signé par appel lorsqu'une commande ou une recharge change. Les points de terminaison appartiennent au compte, et chacun a son propre secret.
https://notpanel.com/api/v3action=webhook.add- Clé d'API requise
- Limite de requêtes atteinte
- Corps :
application/x-www-form-urlencoded
Paramètres
Toujours envoyé
keystringRequis- Votre clé API.
actionstringRequiswebhook.addDoit être la chaîne littérale « webhook.add ».
Paramètres de l'action
urlstring (URL)Requis- Adresse https publique qui reçoit les événements.
eventsstring (CSV)Facultatif- Types d'événements séparés par des virgules auxquels s'abonner, parmi 8. Omettez-le pour tous les recevoir.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));
Remplacez YOUR_API_KEY par la clé de votre page 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"
}Erreurs
- 400
{"error":"Invalid webhook URL","error_code":"INVALID_WEBHOOK_URL","retryable":false}L'adresse n'est pas une adresse https publique, ou elle pointe vers une adresse privée.
- 400
{"error":"Invalid events: not_allowed","error_code":"INVALID_PARAMETER","retryable":false,"details":{"param":"events","rule":"not_allowed"}}Un nom d'événement ne figure pas dans la liste. Vérifiez l'orthographe : les noms ressemblent à
order.completed. - 409
{"error":"Maximum 5 webhook endpoints per account","error_code":"WEBHOOK_LIMIT","retryable":false,"details":{"limit":5}}Le compte a déjà le maximum de 5 points de terminaison. Supprimez-en un d'abord.
Événements
Abonnez-vous à n'importe lequel. Chacun est envoyé comme un appel distinct lorsque le changement est enregistré, et un changement annulé n'envoie rien. Un ping n'est envoyé que par webhook.test.
Types d'événements
order.processing- La commande a été envoyée et attend le début de la livraison.
order.in_progress- La livraison a commencé.
order.completed- La commande a été entièrement livrée.
order.partial- Une partie de la commande a été livrée et le reste remboursé.
order.canceled- La commande a été annulée et remboursée. Remplace l'ancien
order.refunded. order.cancel_refused- Une demande d'annulation a été refusée. La commande continue.
refill.completed- Une recharge a été effectuée. Remplace l'ancien
order.refill_completed. refill.rejected- Une recharge a été refusée. Les données contiennent le motif.
ping- Un événement de test, envoyé uniquement par
webhook.test.
Lister les points de terminaison
action=webhook.list renvoie tous les points de terminaison du compte avec leurs événements, leur état actif et le moment de leur mise en pause, le cas échéant. Le secret n'est jamais renvoyé. Les anciennes clés status, failureCount, lastDeliveredAt et createdAt sont aussi incluses.
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"
}
]Envoyer un événement de test
action=webhook.test met en file un ping vers le point de terminaison. Utilisez-le pour vérifier votre récepteur et votre code de signature. La réponse contient l'ID de la livraison.
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"
}Supprimer un point de terminaison
action=webhook.remove supprime le point de terminaison et son secret. Une livraison déjà en route peut encore arriver ; rien de nouveau n'est mis en file pour lui.
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
}Contenu de la livraison
Chaque livraison est un événement dans un POST avec Content-Type: application/json. Le corps est une enveloppe, et data contient la commande ou la recharge au même format que status et refill_status. Il s'agit de la version de schéma 1.
Enveloppe
idstring- ID unique de l'événement. Utilisez-le pour ignorer les doublons.
typestring- Le type d'événement.
schema_versioninteger- Version de ce format. C'est 1 actuellement.
created_atstring- Quand l'événement a été créé.
sequenceinteger- Augmente à chaque événement du compte, ce qui permet de les ordonner.
dataobjectorderourefill, au format de l'action de lecture correspondante. Vide pour un ping.
En-têtes de la requête
X-Webhook-Signature- HMAC-SHA256 de l'horodatage, d'un point et du corps exact, écrit sous la forme
sha256=suivi des chiffres hexadécimaux. X-Webhook-Timestamp- Heure Unix en secondes de cette tentative. Une nouvelle tentative reçoit un nouvel horodatage et une nouvelle signature.
X-Webhook-Event- Le type d'événement, identique à
typedans le corps. X-Webhook-Delivery-Id- ID de cette livraison, identique à chaque nouvelle tentative.
{
"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"
}
}
}Vérification des signatures
Lisez X-Webhook-Timestamp et les octets exacts du corps. Calculez HMAC-SHA256 sur <timestamp>.<body bytes> avec le secret du point de terminaison, écrivez-le sous la forme sha256=<hex> et comparez-le à X-Webhook-Signature en temps constant. Une nouvelle tentative a le même ID d'événement mais un nouvel horodatage et une nouvelle signature.
Lisez le corps brut et les en-têtes
Calculez et comparez la signature
Ignorez les événements déjà traités
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 exemple détaillé
Utilisez-le pour tester votre code. La signature couvre l'horodatage, un point et le corps exactement comme indiqué, sans espaces ni sauts de ligne supplémentaires.
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=6e8e90578f566d2e14b5c3914eba7e378c4107a5a4d4ddd52c747791f2264fffLivraison et nouvelles tentatives
Au moins une fois
Toute réponse 2xx dans les 10 secondes compte comme livrée. Les redirections ne sont pas suivies, et seuls les 4096 premiers octets de votre réponse sont lus. Le même événement peut arriver plusieurs fois : ignorez donc un ID d'événement déjà traité.
Huit tentatives sur une journée
8 tentatives au total : la première immédiatement, puis 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h après l'événement. Chaque tentative conserve le même ID d'événement et de livraison.
Les points de terminaison se mettent en pause d'eux-mêmes
Après 50 échecs consécutifs, ou 3 jours sans succès, le point de terminaison est mis en pause et le propriétaire du compte est prévenu. Corrigez votre récepteur et ajoutez à nouveau le point de terminaison.
L'ordre n'est pas garanti
Les événements peuvent arriver dans le désordre. Utilisez
sequencepour les ordonner, traitez chaque ID d'événement une seule fois et lisezaction=statusquand vous avez besoin de l'état actuel.