Webhooks
Manage webhooks
Registers a public https address that receives one signed event per call when an order or a refill changes. Endpoints belong to the account, and each one has its own secret.
https://notpanel.com/api/v3action=webhook.add- API key required
- Rate limited
- Body:
application/x-www-form-urlencoded
Parameters
Always sent
keystringRequired- Your API key.
actionstringRequiredwebhook.addMust be the literal string "webhook.add".
Action parameters
urlstring (URL)Required- Public https address that receives the events.
eventsstring (CSV)Optional- Comma-separated event types to subscribe to, out of 8. Leave it out to receive all of them.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));
Replace YOUR_API_KEY with the key from your API page.
{
"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"
}Errors
- 400
{"error":"Invalid webhook URL","error_code":"INVALID_WEBHOOK_URL","retryable":false}The address is not a public https address, or it points to a private one.
- 400
{"error":"Invalid events: not_allowed","error_code":"INVALID_PARAMETER","retryable":false,"details":{"param":"events","rule":"not_allowed"}}An event name is not on the list. Check the spelling: names look like
order.completed. - 409
{"error":"Maximum 5 webhook endpoints per account","error_code":"WEBHOOK_LIMIT","retryable":false,"details":{"limit":5}}The account already has the maximum of 5 endpoints. Remove one first.
Events
Subscribe to any of these. Each one is sent as its own call when the change is saved, and a change that is rolled back sends nothing. A ping is only sent by webhook.test.
Event types
order.processing- The order was sent and is waiting for delivery to begin.
order.in_progress- Delivery has started.
order.completed- The order was fully delivered.
order.partial- Part of the order was delivered and the rest was refunded.
order.canceled- The order was canceled and refunded. It replaces the earlier
order.refunded. order.cancel_refused- A cancel request was refused. The order keeps running.
refill.completed- A refill was done. It replaces the earlier
order.refill_completed. refill.rejected- A refill was refused. The data carries the reason.
ping- A test event, sent only by
webhook.test.
List endpoints
action=webhook.list returns every endpoint of the account with its events, whether it is active and when it was paused, if it was. The secret is never returned. The earlier keys status, failureCount, lastDeliveredAt and createdAt are included too.
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"
}
]Send a test event
action=webhook.test queues a ping to the endpoint. Use it to check your receiver and your signature code. The reply holds the delivery id.
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"
}Remove an endpoint
action=webhook.remove deletes the endpoint and its secret. A delivery already on its way can still arrive; nothing new is queued for it.
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
}Delivery payload
Every delivery is one event in one POST with Content-Type: application/json. The body is an envelope, and data holds the order or the refill in the same format as status and refill_status. This is schema version 1.
Envelope
idstring- Unique id of the event. Use it to ignore duplicates.
typestring- The event type.
schema_versioninteger- Version of this format. It is 1 now.
created_atstring- When the event was created.
sequenceinteger- Rises with every event of the account, so you can put events in order.
dataobjectorderorrefill, in the format of the matching read action. Empty for a ping.
Request headers
X-Webhook-Signature- HMAC-SHA256 of the timestamp, a dot and the exact body, written as
sha256=followed by the hex digits. X-Webhook-Timestamp- Unix time in seconds of this attempt. A retry gets a new timestamp and signature.
X-Webhook-Event- The event type, the same as
typein the body. X-Webhook-Delivery-Id- Id of this delivery, the same on every retry of it.
{
"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"
}
}
}Verifying signatures
Read X-Webhook-Timestamp and the exact bytes of the body. Compute HMAC-SHA256 over <timestamp>.<body bytes> with the endpoint secret, write it as sha256=<hex>, and compare it with X-Webhook-Signature in constant time. A retry has the same event id but a new timestamp and signature.
Read the raw body and the headers
Compute and compare the signature
Skip events you already handled
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);
}A worked example
Use this to test your code. The signature covers the timestamp, a dot and the body exactly as shown, without extra spaces or line breaks.
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=6e8e90578f566d2e14b5c3914eba7e378c4107a5a4d4ddd52c747791f2264fffDelivery and retries
At least once
Any 2xx reply within 10 seconds counts as delivered. Redirects are not followed, and only the first 4096 bytes of your reply are read. The same event can arrive more than once, so ignore an event id you have already handled.
Eight attempts over a day
8 attempts in total: the first at once, then 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h after the event. Every attempt keeps the same event id and delivery id.
Endpoints pause themselves
After 50 failures in a row, or 3 days without a success, the endpoint is paused and the account owner is told. Fix your receiver and add the endpoint again.
Order is not guaranteed
Events can arrive out of order. Use
sequenceto put them in order, handle each event id once, and readaction=statuswhen you need the current state.