Before a webhook changes your customer’s order view, verify its signature against the exact body you received. Then check its timestamp and make sure the event has not already been applied. These are separate checks: authenticity, freshness, and duplicate handling each solve a different problem.
This walkthrough covers NotPanel API endpoints registered with webhook.add, whose deliveries contain an events array. Dashboard-created webhooks share the signing formula but use a single-event body. Keep the receiver’s payload handling matched to the registration you actually use.
Why an HTTPS address is only the beginning
HTTPS protects the connection. It does not tell your receiver that every POST to its public address came from NotPanel. A fabricated completion or refund message should not be able to change the downstream account just because it resembles the expected JSON. Verify first, before any customer-facing update.
HMAC combines a shared secret with a message to produce an authentication value. The receiver computes the same value and compares the two. This is the purpose described by RFC 2104. Both the sender and anyone else holding the secret can create a valid value, so keep the endpoint secret private. Signing authenticates the message; it does not encrypt its contents or replace HTTPS.
The exact NotPanel signing input
Use the decimal text from X-Webhook-Timestamp, followed by one literal period, followed by the untouched raw body. Compute HMAC-SHA256 using the endpoint secret as returned during registration. Use that secret as text; do not hex-decode it merely because it looks hexadecimal.
signed input = timestamp + "." + raw body
signature = HMAC-SHA256(endpoint secret, signed input)
X-Webhook-Timestamp: <Unix seconds>
X-Webhook-Signature: sha256=<64 hexadecimal characters>
X-Webhook-Delivery-Id: <delivery identifier>The body’s event timestamp records the event, while the header timestamp describes the delivery attempt. Use the header value for this signature check. A retry can receive a fresh delivery timestamp and signature; the API delivery ID stays stable across retries. A signature is therefore not a useful duplicate-event key.

A focused verification function for your receiver
The following JavaScript example is code for your receiving application. Pass the original body as a Buffer, the two header values as strings, and the secret from private configuration. Reject missing or malformed headers. Read the body with a configured size limit before calling this function, and preserve it before any automatic JSON parser changes it.
import { createHmac, timingSafeEqual } from "node:crypto";
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) 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);
}The 300-second tolerance is an example receiver policy, not a NotPanel-enforced replay window. It rejects timestamps more than five minutes in the past or future. Keep your receiver’s clock synchronized and investigate clock problems rather than disabling the check. Use the exact timestamp header text in the HMAC input.
The signature format check ensures both comparison buffers are the same length. timingSafeEqual performs the byte comparison; malformed lengths should never reach it. The official comparison documentation also points out that surrounding code needs care: one comparison function does not make an entire receiver timing-safe.
What to do after the signature passes
- Parse the verified body as JSON and validate the expected API envelope. It must contain
eventsas an array and adeliveryId. A successful signature is not a substitute for checking the fields your handler uses. - Compare
X-Webhook-Delivery-Idwith the body’s signeddeliveryId. Read event names from the body; the API batch contract does not requireX-Webhook-Event. Do not trust a separately supplied header to choose a different action from the verified payload. - Save the accepted delivery durably, then return a 2xx response promptly. If your receiver cannot safely save it, returning success would tell the sender to stop trying. Leave the expensive work to your processing path after acceptance.
- Deduplicate each
events[].idin coordination with the order update. An in-memory set disappears after a restart. A delivery ID is useful for tracking attempts, but one event can reach multiple endpoints, so event-level protection remains necessary. - Check that each order reference belongs to the integration you are updating and process only supported event names. Ignore unknown event types safely. If an old event conflicts with a newer order view, query status instead of overwriting the newer result solely because the old message has a valid signature.
Timestamp checks reduce replay exposure outside your allowed window. Durable event IDs handle valid repeated deliveries within that window and across legitimate retries. Keep both. For API batches, the stable event ID is inside the signed body; use that identity rather than the body hash, which can change between attempts.
Diagnose a mismatch without weakening verification
| Symptom | Check first |
|---|---|
| Every signature fails | The correct endpoint secret, used as text; timestamp + period + raw body in the right order. |
| Only formatted or non-English payloads fail | Raw UTF-8 bytes were preserved; no reformatting, escaping changes, or JSON reconstruction occurred. |
| Valid signatures fail freshness | Unix seconds rather than milliseconds, the receiver clock, and its chosen tolerance. |
| Orders update twice | Durable event-ID handling and coordination with the update; signature validity alone cannot stop duplicates. |
| A newer status moves backwards | Delayed arrival or conflicting events; reconcile current status before applying a stale change. |
Log the outcome category, event or delivery identifier, and receipt time where appropriate. Do not log the secret or casually retain full customer payloads in diagnostic output. If the secret is exposed, replace the endpoint registration and update your receiver through a controlled change; do not keep accepting the old secret indefinitely.
Test more than a matching signature
Build test payloads with a secret used only in your own tests. Cover an unchanged valid message, a one-byte edit, a different secret, missing headers, a malformed signature, and timestamps outside your policy in either direction. Include text outside ASCII and confirm that parsing then reformatting the JSON breaks the old signature when the bytes change.
Finally, send the same event twice and restart your receiver between attempts. Signature verification should pass for a legitimate fresh retry, while your event handler should still apply the customer update once. Verification tests establish message authenticity; receiver tests establish how your application behaves afterward. The webhooks and polling guide covers delivery failures and status reconciliation.
Frequently asked questions
What exactly does NotPanel sign?
The X-Webhook-Timestamp value in Unix seconds, a literal period, and the exact raw request body, in that order. The HMAC-SHA256 result is sent as sha256=<hex> in X-Webhook-Signature.
Is a five-minute timestamp tolerance required?
No. Timestamp tolerance is your receiver’s policy. The example uses 300 seconds and rejects timestamps too far in either direction. Choose and monitor a tolerance suitable for your receiver’s clock and delivery conditions.
Why does verification fail after I parse JSON?
Parsing and serializing can change whitespace, property order, or escaping. The signature covers the exact delivered bytes. Preserve the raw body and verify it before parsing the event payload.
Does a valid signature prevent duplicate processing?
No. Legitimate retries can have valid signatures. For API batches, verify deliveryId and process each events[].id once with durable duplicate detection coordinated with your order update.
Should I decode the webhook secret from hexadecimal?
No. Use the secret returned at registration as the secret text. The signature value after sha256= is hexadecimal and should be decoded for byte comparison; the returned secret is used as supplied.
Keep the current NotPanel webhook reference beside your receiver implementation. For placing and recovering orders before tracking their events, see the API integration guide.
