Pandabase sends each event to your webhook endpoint using an HTTP POST request.

Use webhooks to stay in sync with activity in your store, automate workflows, or trigger actions inside your application whenever an event occurs.

See the event reference for the full list of supported events.

Payload

{  "event": "PAYMENT_COMPLETED",  "id": "evt_cm5x7k2a000001j0g8h3f9d2e",  "timestamp": "2026-03-07T12:00:00.000Z",  "data": {    "order": {      "id": "ord_cm5x7k2a000001j0g8h3f9d2e",      "orderNumber": "cs_cm5x7k2a000001j0g8h3f9d2e",      "status": "COMPLETED",      "amount": 2999,      "currency": "USD",      "customFields": { "discord": "johndoe#1234" },      "metadata": { "campaign": "spring_sale", "ref": "partner_abc" },      "items": [        {          "productId": "prd_cm5x7k2a000001j0g8h3f9d2e",          "variantId": null,          "name": "Pro Plan",          "quantity": 1,          "amount": 2999        }      ]    },    "customer": {      "id": "cus_cm5x7k2a000001j0g8h3f9d2e",      "email": "buyer@example.com"    },    "geo": { "ip": "1.2.3.4", "country": "US", "city": "Miami", "region": "FL" }  }}

Amounts are integers in cents.

Signature modes

Mode Format Status
V2 Standard Webhooks v1 Required. Default for new endpoints.
V1 Hex Webhook-Signature + legacy X-Pandabase-* Deprecated. Migrate.

This page documents V2.

The verifiers below — including any Standard Webhooks SDK — only validate V2 endpoints. V1 sends the same Webhook-* header names with hex/millisecond values, so a V2 verifier rejects every V1 delivery. Check your endpoint’s signatureVersion first; if it’s V1, follow the migration guide.

Headers

Header Value
Webhook-Id Message id. Matches payload.id. Deduplicate on this.
Webhook-Timestamp Unix timestamp in seconds. Reject if older than 5 minutes.
Webhook-Signature v1,<base64> HMAC-SHA256 of ${Webhook-Id}.${Webhook-Timestamp}.${rawBody}.

Retries

2xx is success. Anything else — non-2xx, 15s timeout, connection error — retries per the Standard Webhooks schedule over ~75 hours, ±10% jitter.

Attempt Delay Elapsed
1 00:00:00
2 5s 00:00:05
3 5m 00:05:05
4 30m 00:35:05
5 2h 02:35:05
6 5h 07:35:05
7 10h 17:35:05
8 14h 31:35:05
9 20h 51:35:05
10 24h 75:35:05

After all 10 fail, the store owner is emailed (max 1 per endpoint per 6 hours).

Verification

Hash the raw request body. Whitespace and key order change the bytes.

SDK

Node.js
import { Webhook } from "standardwebhooks";const wh = new Webhook(process.env.WEBHOOK_SECRET!);app.post(  "/webhooks/pandabase",  express.raw({ type: "application/json" }),  (req, res) => {    try {      const event = wh.verify(req.body.toString(), req.headers as any);      // event is the parsed JSON body, signature already verified      switch (event.event) {        case "PAYMENT_COMPLETED":          console.log(`Payment completed for order ${event.data.order.id}`);          break;        case "PAYMENT_REFUNDED":          console.log(`Refund issued for order ${event.data.order.id}`);          break;      }      res.status(200).send("OK");    } catch {      res.status(401).send("Invalid signature");    }  },);
Python
from standardwebhooks import Webhookfrom flask import Flask, request, jsonifyapp = Flask(__name__)wh = Webhook(WEBHOOK_SECRET)@app.route("/webhooks/pandabase", methods=["POST"])def handle_webhook():    try:        event = wh.verify(request.data, dict(request.headers))    except Exception:        return jsonify(error="Invalid signature"), 401    if event["event"] == "PAYMENT_COMPLETED":        print(f"Payment completed for order {event['data']['order']['id']}")    return jsonify(message="OK"), 200
Go
import standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"wh, _ := standardwebhooks.NewWebhook(webhookSecret)func webhookHandler(w http.ResponseWriter, r *http.Request) {    body, _ := io.ReadAll(r.Body)    if err := wh.Verify(body, r.Header); err != nil {        http.Error(w, "Invalid signature", http.StatusUnauthorized)        return    }    // body is verified — parse and dispatch    w.WriteHeader(http.StatusOK)}

Manual

Node.js
import crypto from "node:crypto";const TOLERANCE_MS = 5 * 60 * 1000;function verifyWebhook(  headers: Record<string, string>,  rawBody: string,  secret: string,): boolean {  const msgId = headers["webhook-id"];  const timestamp = headers["webhook-timestamp"];  const signatureHeader = headers["webhook-signature"];  if (!msgId || !timestamp || !signatureHeader) return false;  // Webhook-Signature is a space-separated list of `vN,<base64>` entries.  // We only need to match one.  const signatures = signatureHeader    .split(" ")    .filter((s) => s.startsWith("v1,"))    .map((s) => s.slice(3));  const signedPayload = `${msgId}.${timestamp}.${rawBody}`;  const expected = crypto    .createHmac("sha256", secret)    .update(signedPayload)    .digest("base64");  const valid = signatures.some(    (sig) =>      sig.length === expected.length &&      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),  );  if (!valid) return false;  const skewMs = Math.abs(Date.now() - Number(timestamp) * 1000);  return skewMs <= TOLERANCE_MS;}
Python
import hmac, hashlib, base64, timeTOLERANCE_MS = 5 * 60 * 1000def verify(headers, raw_body: bytes, secret: str) -> bool:    msg_id = headers.get("webhook-id")    timestamp = headers.get("webhook-timestamp")    sig_header = headers.get("webhook-signature")    if not msg_id or not timestamp or not sig_header:        return False    signed_payload = f"{msg_id}.{timestamp}.{raw_body.decode()}".encode()    expected = base64.b64encode(        hmac.new(secret.encode(), signed_payload, hashlib.sha256).digest()    ).decode()    sigs = [s[3:] for s in sig_header.split(" ") if s.startswith("v1,")]    if not any(hmac.compare_digest(s, expected) for s in sigs):        return False    skew_ms = abs(int(time.time() * 1000) - int(timestamp) * 1000)    return skew_ms <= TOLERANCE_MS
Go
package webhookimport (    "crypto/hmac"    "crypto/sha256"    "encoding/base64"    "net/http"    "strconv"    "strings"    "time")const toleranceMs = 5 * 60 * 1000func Verify(h http.Header, rawBody []byte, secret string) bool {    msgID := h.Get("Webhook-Id")    ts := h.Get("Webhook-Timestamp")    sigHeader := h.Get("Webhook-Signature")    if msgID == "" || ts == "" || sigHeader == "" {        return false    }    mac := hmac.New(sha256.New, []byte(secret))    mac.Write([]byte(msgID + "." + ts + "." + string(rawBody)))    expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))    matched := false    for _, entry := range strings.Split(sigHeader, " ") {        if !strings.HasPrefix(entry, "v1,") {            continue        }        if hmac.Equal([]byte(entry[3:]), []byte(expected)) {            matched = true            break        }    }    if !matched {        return false    }    tsSec, err := strconv.ParseInt(ts, 10, 64)    if err != nil {        return false    }    skew := time.Now().UnixMilli() - tsSec*1000    if skew < 0 {        skew = -skew    }    return skew <= toleranceMs}

Best practices

  • Return 2xx fast. Do work asynchronously.
  • Verify every request. Reject stale timestamps.
  • Deduplicate on Webhook-Id.
  • Subscribe only to events you need.
  • Don’t rely on delivery order — use the order status field.