Skip to content
Engineering

Verify a NotPanel webhook before it changes a customer’s order

Verify NotPanel webhook signatures from the raw body, timestamp, and endpoint secret. Includes tested code, the event envelope, replay checks, and diagnostics.

A sealed envelope with a padlock on a blue background.

Sameer

Researcher

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 webhooks, whether you add the endpoint on the API page of your dashboard or with webhook.add. Each delivery is one signed event: a JSON object with id, type, schema_version, created_at, sequence and data.

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 shown when you added the endpoint. 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-Event: <event type, such as order.completed>
X-Webhook-Delivery-Id: <delivery identifier>

Only the timestamp and the body are signed. The other headers are not covered by the signature, so treat them as routing and logging hints. The body’s created_at records when the event happened, while X-Webhook-Timestamp describes this attempt. Use the header value for the signature check. A retry gets a new timestamp and signature but keeps the same event id and X-Webhook-Delivery-Id. A signature is therefore not a useful duplicate-event key.

The signing input consists of the timestamp text, one period, and the original body bytes. The endpoint secret is used by HMAC-SHA256 to produce the signature.
Signing formula illustration. The signature authenticates the original byte sequence, including any whitespace or escaping in the body.

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";

/**
 * @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);
}

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.

Download and run the raw-body example

Download receiver.mjs, test-payload.json and test-vector.json into one directory. Check node --version in your receiving environment; this example requires version 22 or newer and no extra packages. These files contain synthetic identifiers and a public test-only secret. Never use that secret for a real endpoint.

The fixed payload is one synthetic order.completed event. It is UTF-8 and ends with one newline byte. Its timestamp is 1791590400 and its test-only secret is notpanel-article-test-only-secret-never-use-live. The expected header is:

X-Webhook-Signature: sha256=900781c4cc5bca41bdd44fec09c6265329bc1dcb777f8d376f0c7a362a366214

Run the deterministic check from that directory. It freezes only the test clock at the fixture timestamp and checks the expected signature, changed bytes, wrong secret, malformed headers and expired or future timestamps. An unchanged fixture should print “Fixture passed”. Do not disable freshness checks in a live receiver.

node receiver.mjs --check-fixture

To test an actual local POST, start the receiver below. In a second terminal in the same directory, run node receiver.mjs --send-fixture. The sender signs the same synthetic body with a fresh delivery-header timestamp; the expected response is HTTP 204. The fixed signature above belongs only to the fixed timestamp.

WEBHOOK_SECRET=notpanel-article-test-only-secret-never-use-live \
WEBHOOK_INBOX_FILE="$PWD/test-inbox/deliveries.jsonl" \
node receiver.mjs

This local example listens only on 127.0.0.1:8787/notpanel-webhook, limits bodies to 1 MiB, verifies raw bytes before parsing, checks the event envelope and flushes accepted deliveries to a private inbox file before returning 204. A failed save returns 503. It deliberately keeps duplicate deliveries and never changes an order. It is a learning example: a real receiver still needs public HTTPS, durable storage suitable for its host, capacity monitoring and the event-level processing safeguards below. A localhost URL cannot be registered with NotPanel.

What to do after the signature passes

  1. Parse the verified body as JSON and check the envelope: id (string), type (string), schema_version (integer), created_at, sequence (integer) and data (object; empty for the ping that webhook.test sends). A successful signature is not a substitute for checking the fields your handler uses.
  2. Read the event type from the verified body’s type. X-Webhook-Event and X-Webhook-Delivery-Id are convenient for routing and logging, but they are not signed. Never let them choose a different action from the verified body.
  3. 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.
  4. Deduplicate on the envelope’s id in 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 several of your endpoints, so event-level protection remains necessary.
  5. Check that each order reference belongs to the integration you are updating and process only supported event types. Ignore unknown event types and unknown fields safely. Events can arrive out of order: use sequence to order them, and if an old event conflicts with a newer order view, read action=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. The event id is inside the signed body and is the same on every retry and for every endpoint that receives the event; use that identity rather than a signature, which changes with each attempt.

Diagnose a mismatch without weakening verification

Common mismatch checks
SymptomCheck first
Every signature failsThe correct endpoint secret, used as text; timestamp + period + raw body in the right order.
Only formatted or non-English payloads failRaw UTF-8 bytes were preserved; no reformatting, escaping changes, or JSON reconstruction occurred.
Valid signatures fail freshnessUnix seconds rather than milliseconds, the receiver clock, and its chosen tolerance.
Orders update twiceDurable event-ID handling and coordination with the update; signature validity alone cannot stop duplicates.
A newer status moves backwardsDelayed 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, rotate it in the webhooks section of your dashboard’s API page (the new secret is shown once), or remove the endpoint and add it again. Then 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 carry valid signatures. Process each event id once, with durable duplicate detection coordinated with your order update.

Should I decode the webhook secret from hexadecimal?

No. Use the secret shown when you added the endpoint, or when you last rotated it, as the secret text. The signature value after sha256= is hexadecimal and should be decoded for byte comparison; the 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.

Share this post