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:
Pantech-Signature: t=1792687211,v1=1a5d101f814e8d6654baea274139a16ec4eea3a5f7619322edbfabf799cabb0etis when the delivery was signed, in Unix seconds.v1is 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:
Pantech-Signature: t=1792687211,v1=1a5d…bb0e,v1=c54f…708eTo verify a delivery:
- 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.
- Split the header on
,, then each part on its first=. Keept, and everyv1. Ignore anything else, so a scheme added later does not break you. - Refuse the delivery if
tis more than five minutes from your clock. This stops a captured delivery from being replayed later. Keep your server's clock synced with NTP. - Compute HMAC-SHA256 of
t, a., and the raw body, keyed with your secret, as lowercase hex. - Accept the delivery if it equals any
v1value. 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.
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);With Flask. request.get_data() returns the raw bytes; call it before anything reads request.json.
import hashlib
import hmac
import json
import os
import time
from typing import Optional
from flask import Flask, abort, request
SECRET = os.environ["PANTECH_WEBHOOK_SECRET"] # whsec_…, the whole string
TOLERANCE_SECONDS = 5 * 60
def verify_pantech_signature(raw_body: bytes, header: Optional[str], secret: str, now: Optional[float] = None) -> bool:
"""True if `header` holds a valid signature of `raw_body` made with `secret` in the last five minutes."""
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if timestamp is None or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
return False
if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
app = Flask(__name__)
@app.post("/hooks/pantech")
def pantech_webhook():
raw_body = request.get_data()
if not verify_pantech_signature(raw_body, request.headers.get("Pantech-Signature"), SECRET):
abort(400)
event = json.loads(raw_body)
# Queue the event for a worker, and skip an event["id"] you have already handled.
print(event["id"], event["type"], event["data"])
return "", 204With FastAPI. Take the Request and await request.body() for the raw bytes; do not declare a Pydantic model for the body, which parses it first.
import hashlib
import hmac
import json
import os
import time
from typing import Optional
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
SECRET = os.environ["PANTECH_WEBHOOK_SECRET"] # whsec_…, the whole string
TOLERANCE_SECONDS = 5 * 60
def verify_pantech_signature(raw_body: bytes, header: Optional[str], secret: str, now: Optional[float] = None) -> bool:
"""True if `header` holds a valid signature of `raw_body` made with `secret` in the last five minutes."""
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if timestamp is None or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
return False
if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
app = FastAPI()
def handle(event: dict) -> None:
# Skip an event["id"] you have already handled: delivery is at least once.
print(event["id"], event["type"], event["data"])
@app.post("/hooks/pantech", status_code=204)
async def pantech_webhook(request: Request, background: BackgroundTasks) -> Response:
raw_body = await request.body()
if not verify_pantech_signature(raw_body, request.headers.get("pantech-signature"), SECRET):
raise HTTPException(status_code=400, detail="invalid signature")
background.add_task(handle, json.loads(raw_body)) # runs after the response is sent
return Response(status_code=204)With the standard library's net/http (Go 1.22 or later, for the method in the route pattern). The handler reads the body itself, so it has the raw bytes.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// verifyPantechSignature checks the Pantech-Signature header against the raw
// request body. It returns nil when one of the v1 signatures matches and the
// timestamp is within five minutes of now.
func verifyPantechSignature(body []byte, header, secret string, now time.Time) error {
var timestamp string
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch key {
case "t":
timestamp = value
case "v1":
signatures = append(signatures, value)
}
}
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || len(signatures) == 0 {
return errors.New("malformed Pantech-Signature header")
}
if age := now.Sub(time.Unix(seconds, 0)); age > tolerance || age < -tolerance {
return errors.New("timestamp outside the tolerance")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, signature := range signatures {
given, err := hex.DecodeString(signature)
if err == nil && hmac.Equal(given, expected) {
return nil
}
}
return errors.New("no matching signature")
}
type event struct {
ID string `json:"id"`
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
func main() {
secret := os.Getenv("PANTECH_WEBHOOK_SECRET") // whsec_…, the whole string
http.HandleFunc("POST /hooks/pantech", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
if err := verifyPantechSignature(body, r.Header.Get("Pantech-Signature"), secret, time.Now()); err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
var e event
if err := json.Unmarshal(body, &e); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
// Hand e to a queue or worker here, and skip an e.ID you have already handled.
log.Printf("event %s %s %s", e.ID, e.Type, e.Data)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}Plain PHP. php://input is the raw body; $_POST is empty for a JSON request. In Laravel or Symfony, $request->getContent() returns the same bytes.
<?php
const PANTECH_TOLERANCE_SECONDS = 300;
/** True if $header holds a valid signature of $rawBody made with $secret in the last five minutes. */
function verify_pantech_signature(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
if ($header === null || $header === '') {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
[$key, $value] = $pair;
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || !ctype_digit($timestamp) || count($signatures) === 0) {
return false;
}
if (abs(($now ?? time()) - (int) $timestamp) > PANTECH_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$secret = getenv('PANTECH_WEBHOOK_SECRET'); // whsec_…, the whole string
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_PANTECH_SIGNATURE'] ?? null;
if ($secret === false || !verify_pantech_signature($rawBody, $header, $secret)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
// Queue $event for a worker, and skip an $event['id'] you have already handled.
error_log(sprintf('event %s %s', $event['id'], $event['type']));
http_response_code(204);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:
$ 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 ContentChange 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
- Rotate the secret. From now on deliveries carry two
v1values, one from each secret. - 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. - 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}returnspayload, the body that was sent, andlast_status_codeandlast_responseshow what your receiver answered.