Verification and handler checklist
Every delivery is signed. Verify the signature before acting on a payload.
Why verify
A valid signature proves that the request came from Peer and that the payload was not altered in transit. The timestamp check proves it is not a replay of an old delivery.
Signature format
Each webhook includes these headers:
| Header | Description |
|---|---|
X-Webhook-Id | Unique event ID |
X-Webhook-Timestamp | Unix timestamp (seconds) |
X-Webhook-Signature | HMAC-SHA256 signature (hex) |
Verification algorithm
The signature is computed as:
HMAC-SHA256(secret, timestamp + "." + payload)
Where:
secretis your webhook secret (from webhook creation)timestampis fromX-Webhook-Timestampheaderpayloadis the raw request body
Implementation examples
Node.js / Express
import express from 'express';
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
function verifyWebhookSignature(
payload: string,
signature: string,
timestamp: string
): boolean {
// Check timestamp is recent (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
const webhookTimestamp = parseInt(timestamp, 10);
if (Math.abs(now - webhookTimestamp) > 300) {
console.error('Webhook timestamp too old');
return false;
}
// Compute expected signature
const signatureBase = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signatureBase)
.digest('hex');
// Use timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
} catch {
return false;
}
}
const app = express();
app.post(
'/webhooks/zkp2p',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-webhook-signature'] as string;
const timestamp = req.headers['x-webhook-timestamp'] as string;
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, timestamp)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
// Process event...
res.status(200).send('OK');
}
);
Python / Flask
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
def verify_webhook_signature(payload: bytes, signature: str, timestamp: str) -> bool:
# Check timestamp is recent
now = int(time.time())
webhook_timestamp = int(timestamp)
if abs(now - webhook_timestamp) > 300:
return False
# Compute expected signature
signature_base = f"{timestamp}.{payload.decode('utf-8')}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
signature_base.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(signature, expected_signature)
app = Flask(__name__)
@app.route('/webhooks/zkp2p', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
timestamp = request.headers.get('X-Webhook-Timestamp')
payload = request.data
if not verify_webhook_signature(payload, signature, timestamp):
abort(401)
event = request.get_json()
# Process event...
return 'OK', 200
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
var webhookSecret = []byte(os.Getenv("WEBHOOK_SECRET"))
func verifyWebhookSignature(payload []byte, signature, timestamp string) bool {
// Check timestamp
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
age := now - ts
if age < 0 {
age = -age
}
if age > 300 {
return false
}
// Compute expected signature
signatureBase := timestamp + "." + string(payload)
mac := hmac.New(sha256.New, webhookSecret)
mac.Write([]byte(signatureBase))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Timing-safe comparison
return hmac.Equal(
[]byte(signature),
[]byte(expectedSignature),
)
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
signature := r.Header.Get("X-Webhook-Signature")
timestamp := r.Header.Get("X-Webhook-Timestamp")
payload, _ := io.ReadAll(r.Body)
if !verifyWebhookSignature(payload, signature, timestamp) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Process event...
w.WriteHeader(http.StatusOK)
}
Handler checklist
- Verify the signature and the timestamp on every delivery, including in development. Reject anything more than 5 minutes from your clock in either direction.
- Compare signatures with a timing-safe function. A plain
==leaks timing information. - Verify the raw body, then parse it, so you check the exact bytes you received.
- Respond 2xx first, process asynchronously after.
- Deduplicate on
X-Webhook-Id. Use it as your idempotency key. - Reconcile the full snapshots against
data.order.id. Events can repeat and arrive out of order, so treat each delivery as current state, not as a step. - Keep fulfillment state and chargeback state in separate fields. A chargeback event does not
undo
SETTLEDorFULFILLED, andchargebackStatusis not monotonic: a later settlement can correctly move an order fromCHARGEBACKEDtoPARTIALLY_CHARGEBACKED. - Read
payment.penaltiesbefore you treat a lownetSettledUsdcAmountas an underpayment. A non-empty array means the attestation reduced the credited amount, for example because the customer sent a Venmo or PayPal payment with purchase protection on. See payment penalties. - Release goods only on
ORDER_FULFILLED, or ondata.order.statusofFULFILLED. No other event or redirect is a release signal. - Log
eventId,type, andorderIdon every delivery you handle. - Keep the signing secret in an environment variable, never log it, and rotate it if it leaks.