# Migrate webhook signatures (/developers/webhooks/migrate-signatures)



<Callout type="warn">
  **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.
</Callout>

Endpoints run in **V1** or **V2**.

| Mode   | Signature                                                 | Status                                            |
| ------ | --------------------------------------------------------- | ------------------------------------------------- |
| **V2** | [Standard Webhooks](https://www.standardwebhooks.com) 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.

<Callout type="warn">
  **A Standard Webhooks SDK rejects V1 deliveries.** Both modes use the same
  `Webhook-*` header names, but V1 uses a hex signature, a different signed
  string, and a timestamp in **milliseconds**. A V2 verifier expects seconds
  and rejects that timestamp as outside the 5-minute window. This can appear
  as a "timestamp" or "stale" error.

  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](#troubleshooting).
</Callout>

### Telling the modes apart on the wire [#telling-the-modes-apart-on-the-wire]

Check the `Webhook-Signature` format to identify the mode:

| Signature header starts with | Mode | Verify as                           |
| ---------------------------- | ---- | ----------------------------------- |
| `v1,` (then base64)          | V2   | Standard Webhooks                   |
| bare hex (no prefix)         | V1   | [`verifyV1`](#v1-replay-protection) |

A receiver that supports both modes can use this prefix to choose a verifier. The authoritative answer for a
given endpoint is always its `signatureVersion`.

## V1 → V2 [#v1--v2]

<Steps>
  <Step>
    ### Deploy a V2 verifier [#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
    <a href="/developers/webhooks/overview#verification">verification guide</a>,
    or install a

    <a href="https://www.standardwebhooks.com/#libraries">
      Standard Webhooks SDK
    </a>

    to verify deliveries.

    <Callout type="info">
      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`](#v1-replay-protection) when
      `Webhook-Signature` is bare hex (see
      [Telling the modes apart](#telling-the-modes-apart-on-the-wire)). High-volume
      endpoints should not deploy a V2-only verifier first.
    </Callout>
  </Step>

  <Step>
    ### Switch your webhook version [#switch-your-webhook-version]

    In your merchant dashboard, navigate to:

    <strong>
      Your Store → Developers → Webhooks
    </strong>

    Open the webhook dropdown menu, click **Edit**, and
    select the webhook version you want to enable.
  </Step>

  <Step>
    ### Send a test event [#send-a-test-event]

    After updating the version, return to:

    <strong>
      Your Store → Developers → Webhooks
    </strong>

    Open the dropdown menu and click <strong>Send test</strong> to verify
    that your endpoint is receiving and validating events correctly.
  </Step>

  <Step>
    ### Roll back if needed [#roll-back-if-needed]

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

    Go back to <strong>Your Store → Developers → Webhooks</strong>,
    click <strong>Edit</strong>, and select the previous version.
  </Step>
</Steps>

### Differences [#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 [#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.

<Callout type="warn">
  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.
</Callout>

| 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.

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Go">
      Go
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```typescript
    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;
    }
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    import hmac, hashlib, time

    TOLERANCE_MS = 5 * 60 * 1000

    def 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
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Go">
    ```go
    package webhook

    import (
        "crypto/hmac"
        "crypto/sha256"
        "encoding/hex"
        "strconv"
        "time"
    )

    const toleranceMs = 5 * 60 * 1000

    func 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
    }
    ```
  </CodeBlockTab>
</CodeBlockTabs>

## Example deliveries [#example-deliveries]

**V2:**

```http
POST /webhooks/pandabase HTTP/1.1
Webhook-Id: evt_cm5x7k2a000001j0g8h3f9d2e
Webhook-Timestamp: 1715688123
Webhook-Signature: v1,7ZH9F8sZqxQk6vJtN5cBp0LmRwXyP3aT8eK1nDhU2gI=

{ "event": "PAYMENT_COMPLETED", "id": "evt_cm5x7k2a000001j0g8h3f9d2e", ... }
```

**V1:**

```http
POST /webhooks/pandabase HTTP/1.1
Webhook-Id: whk_abc/job_xyz
Webhook-Timestamp: 1715688123456
Webhook-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Pandabase-Idempotency: whk_abc/job_xyz
X-Pandabase-Timestamp: 1715688123456
X-Pandabase-Signature: 0a4d55a8d778e5022fab701977c5d840bbc486d0

{ "event": "PAYMENT_COMPLETED", ... }
```

## Pitfalls [#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 [#troubleshooting]

<Accordions>
  <Accordion title="A Standard Webhooks SDK rejects every delivery / timestamp or 'stale' errors">
    Check your endpoint's `signatureVersion`. Standard Webhooks SDKs expect V2,
    but V1 uses a hex signature, a different signed string, and a timestamp in
    milliseconds. These differences cause verification to fail.

    Switch the endpoint to V2, or use [`verifyV1`](#v1-replay-protection) until
    you migrate.
  </Accordion>

  <Accordion title="Some deliveries verify, others don't, on the same endpoint">
    You likely have a verifier hard-coded to one mode while the endpoint's
    `signatureVersion` changed (or you're mid-rollout across machines). Branch on
    the `Webhook-Signature` prefix — `v1,` is V2, bare hex is V1 — or pin both
    sides to the same mode. See
    [Telling the modes apart](#telling-the-modes-apart-on-the-wire).
  </Accordion>

  <Accordion title="Signature mismatch even though the secret is correct">
    Check whether your handler is hashing a re-serialized body. Hash the **raw** bytes
    exactly as received — JSON re-encoding changes whitespace and key order, which
    changes the signature. In Express, use `express.raw({ type: "application/json" })`.
  </Accordion>
</Accordions>

## FAQ [#faq]

<Accordions>
  <Accordion title="Do I have to upgrade to V2?">
    Yes. V1 is being deprecated. Migrate before the cutoff or your endpoint will be auto-migrated.
  </Accordion>

  <Accordion title="When does V1 go away?">
    After a 60-day notice period, emailed to your store contact. The deprecation date will be announced in the changelog.
  </Accordion>

  <Accordion title="Same secret for V1 and V2?">
    Yes.
  </Accordion>

  <Accordion title="Can endpoints in one store be on different modes?">
    Yes during migration. `signatureVersion` is per-endpoint.
  </Accordion>
</Accordions>
