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
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.
In your merchant dashboard, navigate to:
Your Store → Developers → WebhooksOpen the webhook dropdown menu, click Edit, and select the webhook version you want to enable.
After updating the version, return to:
Your Store → Developers → WebhooksOpen the dropdown menu and click Send test to verify that your endpoint is receiving and validating events correctly.
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.
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;}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_MSpackage 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-Signatureis a space-separated list — check eachv1,…entry. - Keep your server NTP-synced. >5 min drift rejects valid events.