Skip to main content

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:

HeaderDescription
X-Webhook-IdUnique event ID
X-Webhook-TimestampUnix timestamp (seconds)
X-Webhook-SignatureHMAC-SHA256 signature (hex)

Verification algorithm​

The signature is computed as:

HMAC-SHA256(secret, timestamp + "." + payload)

Where:

  • secret is your webhook secret (from webhook creation)
  • timestamp is from X-Webhook-Timestamp header
  • payload is 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 SETTLED or FULFILLED, and chargebackStatus is not monotonic: a later settlement can correctly move an order from CHARGEBACKED to PARTIALLY_CHARGEBACKED.
  • Read payment.penalties before you treat a low netSettledUsdcAmount as 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 on data.order.status of FULFILLED. No other event or redirect is a release signal.
  • Log eventId, type, and orderId on every delivery you handle.
  • Keep the signing secret in an environment variable, never log it, and rotate it if it leaks.