Documentation

esc
    Webhooks / Verify signatures

    Verify webhook signatures

    Check that a webhook delivery came from Pantech and was not changed or replayed, with complete examples in Node.js, Python, Go and PHP.

    Your webhook URL is public, so anyone can send it a request. Every delivery from Pantech is signed with your webhook's secret: check the signature before you act on an event, and refuse anything that does not match.

    How the signature works

    Each delivery carries a Pantech-Signature header:

    HTTP
    Pantech-Signature: t=1792687211,v1=1a5d101f814e8d6654baea274139a16ec4eea3a5f7619322edbfabf799cabb0e
    • t is when the delivery was signed, in Unix seconds.
    • v1 is the hex-encoded HMAC-SHA256 of the string <t>.<raw body>, keyed with the webhook's secret.

    The secret is the whole string you were given, whsec_ included, used as UTF-8 bytes. Do not strip the prefix or decode it.

    For 24 hours after you rotate the secret the header has two v1 values, one made with the new secret and one with the old:

    HTTP
    Pantech-Signature: t=1792687211,v1=1a5d…bb0e,v1=c54f…708e

    To verify a delivery:

    1. Read the raw body, the exact bytes received, before any framework parses it. Parsing JSON and serializing it again changes the bytes (spacing, key order, escapes) and the signature no longer matches.
    2. Split the header on ,, then each part on its first =. Keep t, and every v1. Ignore anything else, so a scheme added later does not break you.
    3. Refuse the delivery if t is more than five minutes from your clock. This stops a captured delivery from being replayed later. Keep your server's clock synced with NTP.
    4. Compute HMAC-SHA256 of t, a ., and the raw body, keyed with your secret, as lowercase hex.
    5. Accept the delivery if it equals any v1 value. Compare in constant time, never with ==, so timing does not leak the signature.

    Answer 400 when verification fails. A delivery you refuse is retried like any other failure, so a receiver with the wrong secret shows up in the webhook's deliveries as http_400.

    Examples

    Each example is a complete receiver: it reads the secret from PANTECH_WEBHOOK_SECRET, verifies the signature over the raw body, and answers 204 straight away. Do the real work after answering: a delivery that takes longer than 10 seconds to answer is retried.

    With Express. express.raw() keeps the body as a Buffer of the exact bytes received. Mount it on the webhook route and do not let express.json() run before it, or the raw bytes are gone.

    server.mjs
    import crypto from "node:crypto";
    import express from "express";
    
    const SECRET = process.env.PANTECH_WEBHOOK_SECRET; // whsec_…, the whole string
    const TOLERANCE_SECONDS = 5 * 60;
    
    // True if `header` holds a valid signature of `rawBody` (a Buffer) made with
    // `secret` within the last five minutes.
    function verifyPantechSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
      if (typeof header !== "string") return false;
      let timestamp = null;
      const signatures = [];
      for (const part of header.split(",")) {
        const eq = part.indexOf("=");
        if (eq === -1) continue;
        const key = part.slice(0, eq).trim();
        const value = part.slice(eq + 1).trim();
        if (key === "t") timestamp = value;
        else if (key === "v1") signatures.push(value);
      }
      if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
      if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
    
      const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
      return signatures.some((signature) => {
        const given = Buffer.from(signature, "hex");
        return given.length === expected.length && crypto.timingSafeEqual(given, expected);
      });
    }
    
    const app = express();
    
    app.post("/hooks/pantech", express.raw({ type: "application/json" }), (req, res) => {
      if (!verifyPantechSignature(req.body, req.get("Pantech-Signature"), SECRET)) {
        return res.status(400).send("invalid signature");
      }
      const event = JSON.parse(req.body.toString("utf8"));
      res.sendStatus(204); // answer first, then work
      handle(event).catch((err) => console.error("webhook", event.id, err));
    });
    
    async function handle(event) {
      // Skip an event.id you have already handled: delivery is at least once.
      console.log(event.id, event.type, event.data);
    }
    
    app.listen(3000);

    Try your receiver

    Before you point a webhook at it, sign a request yourself with the same algorithm and send it to your receiver running locally:

    Terminal
    $ export PANTECH_WEBHOOK_SECRET=whsec_test$ body='{"id":"evt_local","type":"webhook.test","api_version":"2026-10-22","data":{}}'$ t=$(date +%s)$ sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$PANTECH_WEBHOOK_SECRET" -hex | sed 's/^.* //')$ curl -i http://localhost:3000/hooks/pantech \    -H "Content-Type: application/json" \    -H "Pantech-Signature: t=$t,v1=$sig" \    --data-binary "$body"HTTP/1.1 204 No Content

    Change one character of $body after signing and send it again: the receiver should answer 400. Then deploy it, create the webhook, and send a test event: a webhook.test delivery signed with the real secret.

    Rotating without dropping events

    1. Rotate the secret. From now on deliveries carry two v1 values, one from each secret.
    2. Put the new secret in your receiver and deploy it. Your old receiver still matches the old secret's v1; the new one matches the new secret's.
    3. Within 24 hours, before the old secret stops signing, make sure every instance of your receiver runs with the new secret.

    A receiver that checks every v1, as the examples do, needs no special handling for a rotation.

    When verification fails

    • Every delivery fails. Check you have the secret of this webhook, the whole whsec_… string, and that it was not rotated since. Check you sign the raw body: if a framework parsed the JSON first, the bytes you sign are not the bytes sent.
    • Some deliveries fail. Check the server's clock: a clock more than five minutes out rejects every delivery, or only some if it drifts. Check a proxy in front of the receiver does not rewrite the body, for example by changing the character encoding.
    • Compare the delivery. GET /webhooks/{webhook_id}/deliveries/{delivery_id} returns payload, the body that was sent, and last_status_code and last_response show what your receiver answered.

    Was this page helpful?

    Updated