V1 is being deprecated. All endpoints must move to V2. New endpoints are V2 by default; existing V1 endpoints will be auto-migrated after a 60-day notice period.

Endpoints run in V1 or V2.

Mode Signature Status
V2 Standard Webhooks v1 Required. Default for endpoints after 2026-05-15.
V1 Hex over ${timestamp}.${rawBody} (ms) + X-Pandabase-* Deprecated. Migrate before the cutoff.

Check your mode with GET /v2/stores/:storeId/webhooks/:webhookId (signatureVersion) or the dashboard.

A Standard Webhooks SDK will reject a V1 endpoint — this is the most common migration failure. V1 and V2 send the same Webhook-* header names, but a V1 endpoint fills them with V1 values: bare-hex Webhook-Signature (not v1,<base64>), a different signed string, and a millisecond Webhook-Timestamp. A strict V2 verifier reads that millisecond value as a far-future time outside the 5-minute window and rejects every delivery — often surfacing as a “timestamp” or “stale” error that looks like a sender bug but isn’t.

Confirm signatureVersion is V2 before you point a Standard Webhooks SDK (or any V2-only verifier) at the endpoint. Doing it the other way around drops live events until you switch. See Troubleshooting.

Telling the modes apart on the wire

You never have to guess — the Webhook-Signature format is unambiguous:

Signature header starts with Mode Verify as
v1, (then base64) V2 Standard Webhooks
bare hex (no prefix) V1 verifyV1

A polymorphic receiver can branch on this prefix. The authoritative answer for a given endpoint is always its signatureVersion.

V1 → V2

Deploy a V2 verifier

We require webhook signatures to be verified using the raw request body and your webhook signing secret.

You can use the snippets from the verification guide, or install a

Standard Webhooks SDK

for the fastest setup.

Your endpoint is still on V1 until you complete the next step, so a V2-only verifier will reject every live delivery in the gap between deploying it and switching the version. Until you’ve switched and tested, either keep your existing V1 verifier running, or accept the V2 signature and fall back to verifyV1 when Webhook-Signature is bare hex (see Telling the modes apart). High-volume endpoints should not deploy a V2-only verifier first.

Switch your webhook version

In your merchant dashboard, navigate to:

Your Store → Developers → Webhooks

Open the webhook dropdown menu, click Edit, and select the webhook version you want to enable.

Send a test event

After updating the version, return to:

Your Store → Developers → Webhooks

Open the dropdown menu and click Send test to verify that your endpoint is receiving and validating events correctly.

Roll back if needed

You can revert to a previous webhook version at any time.

Go back to Your Store → Developers → Webhooks, click Edit, and select the previous version.

Differences

V1 V2
Encoding Hex v1,<base64> (space-separated list)
Signed string ${timestamp}.${rawBody} ${Webhook-Id}.${timestamp}.${rawBody}
Webhook-Timestamp Milliseconds Seconds
Webhook-Id ${webhookId}/${jobId} Message id (matches payload.id)
X-Pandabase-* headers Sent Not sent

V1 replay protection

Moving from X-Pandabase-* to the V1 Webhook-* headers. V1 endpoints already send Webhook-* alongside the legacy headers, so you can move off X-Pandabase-* (which has no replay protection) without switching modes yet.

These V1 Webhook-* headers are not Standard Webhooks compatible — same header names, but hex encoding (no v1, prefix), a ${timestamp}.${rawBody} signed string, and a millisecond timestamp. Verify them with verifyV1 below, not a Standard Webhooks SDK. To use an off-the-shelf SDK, migrate the endpoint to V2 first.

Header X-Pandabase-* Webhook-* (V1)
Signature X-Pandabase-Signature Webhook-Signature
Timestamp X-Pandabase-Timestamp (ms) Webhook-Timestamp (ms)
Idempotency X-Pandabase-Idempotency Webhook-Id
Signed string rawBody ${timestamp}.${rawBody}
Replay protection None Reject events older than 5 minutes

Same secret, same hex encoding.

Node.js
import crypto from "node:crypto";const TOLERANCE_MS = 5 * 60 * 1000;export function verifyV1(  headers: Record<string, string>,  rawBody: string,  secret: string,): boolean {  const ts = headers["webhook-timestamp"];  const sig = headers["webhook-signature"];  if (!ts || !sig) return false;  const expected = crypto    .createHmac("sha256", secret)    .update(`${ts}.${rawBody}`)    .digest("hex");  if (expected.length !== sig.length) return false;  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return false;  return Math.abs(Date.now() - Number(ts)) <= TOLERANCE_MS;}
Python
import hmac, hashlib, timeTOLERANCE_MS = 5 * 60 * 1000def verify_v1(headers, raw_body: bytes, secret: str) -> bool:    ts = headers.get("webhook-timestamp")    sig = headers.get("webhook-signature")    if not ts or not sig:        return False    expected = hmac.new(        secret.encode(), f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256    ).hexdigest()    if not hmac.compare_digest(expected, sig):        return False    return abs(int(time.time() * 1000) - int(ts)) <= TOLERANCE_MS
Go
package webhookimport (    "crypto/hmac"    "crypto/sha256"    "encoding/hex"    "strconv"    "time")const toleranceMs = 5 * 60 * 1000func VerifyV1(headers map[string]string, rawBody []byte, secret string) bool {    ts := headers["Webhook-Timestamp"]    sig := headers["Webhook-Signature"]    if ts == "" || sig == "" {        return false    }    mac := hmac.New(sha256.New, []byte(secret))    mac.Write([]byte(ts + "." + string(rawBody)))    if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig)) {        return false    }    tsInt, err := strconv.ParseInt(ts, 10, 64)    if err != nil {        return false    }    skew := time.Now().UnixMilli() - tsInt    if skew < 0 {        skew = -skew    }    return skew <= toleranceMs}

Example deliveries

V2:

POST /webhooks/pandabase HTTP/1.1Webhook-Id: evt_cm5x7k2a000001j0g8h3f9d2eWebhook-Timestamp: 1715688123Webhook-Signature: v1,7ZH9F8sZqxQk6vJtN5cBp0LmRwXyP3aT8eK1nDhU2gI={ "event": "PAYMENT_COMPLETED", "id": "evt_cm5x7k2a000001j0g8h3f9d2e", ... }

V1:

POST /webhooks/pandabase HTTP/1.1Webhook-Id: whk_abc/job_xyzWebhook-Timestamp: 1715688123456Webhook-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08X-Pandabase-Idempotency: whk_abc/job_xyzX-Pandabase-Timestamp: 1715688123456X-Pandabase-Signature: 0a4d55a8d778e5022fab701977c5d840bbc486d0{ "event": "PAYMENT_COMPLETED", ... }

Pitfalls

  • Hash the raw body, not a re-serialized JSON object.
  • Use timingSafeEqual / hmac.compare_digest. Never ==.
  • Reject stale timestamps. The signature alone doesn’t stop replay.
  • V2 timestamps are seconds, V1 are milliseconds.
  • V2 Webhook-Signature is a space-separated list — check each v1,… entry.
  • Keep your server NTP-synced. >5 min drift rejects valid events.

Troubleshooting

FAQ