Signature Verification
Verify webhook authenticity before processing the event.
Every webhook PacSpace sends is signed with your webhook secret. Always verify the signature before processing the payload.
Signature Headers
Each webhook request includes these headers:
| Header | Description | Example |
|---|---|---|
X-PacSpace-Signature | HMAC-SHA256 signature of the payload | v1=5a2f8c... |
X-PacSpace-Timestamp | Unix timestamp (seconds) of when the webhook was sent | 1739270400 |
X-Webhook-Event | The event type | delta.verified |
X-Event-ID | Unique delivery identifier (for idempotency) | evt_a1b2c3d4 |
How Signing Works
PacSpace constructs a signed content string, computes an HMAC-SHA256 digest using your webhook secret, and sends it in the X-PacSpace-Signature header.
The signed content format is:
{timestamp}.{payload}
Where:
timestampis the value ofX-PacSpace-Timestamppayloadis the raw JSON request body (exactly as sent, with no modifications)
Verification Algorithm
TypeScript
import crypto from 'node:crypto';
function verifyWebhookSignature(request: {
headers: Record<string, string | undefined>;
rawBody: string;
}, secret: string): boolean {
const signature = request.headers['x-pacspace-signature'];
const timestamp = request.headers['x-pacspace-timestamp'];
const body = request.rawBody; // Must be the raw string, not parsed JSON
if (!signature || !timestamp) return false;
// 1. Reconstruct the signed content
const signedContent = `${timestamp}.${body}`;
// 2. Compute HMAC-SHA256 with your webhook secret
const expectedSignature = 'v1=' + crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
// 3. Constant-time comparison to prevent timing attacks
const expected = Buffer.from(expectedSignature, 'utf8');
const received = Buffer.from(signature, 'utf8');
if (expected.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
Express.js example:
import express from 'express';
const app = express();
// Important: capture the raw body for signature verification
app.use('/webhooks', express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
app.post('/webhooks/pacspace', (req, res) => {
const secret = process.env.PACSPACE_WEBHOOK_SECRET;
if (!verifyWebhookSignature(req, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Signature verified. Process the event.
const { event, data } = req.body;
console.log(`Received ${event}:`, data.receiptId);
// Respond immediately, process async
res.status(200).json({ received: true });
// Process the event asynchronously
processWebhookAsync(event, data);
});
Python
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, timestamp: str, secret: str) -> bool:
signed_content = f"{timestamp}.{payload.decode('utf-8')}"
expected = 'v1=' + hmac.new(
secret.encode('utf-8'),
signed_content.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Flask example:
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/pacspace', methods=['POST'])
def handle_webhook():
secret = os.environ['PACSPACE_WEBHOOK_SECRET']
signature = request.headers.get('X-PacSpace-Signature', '')
timestamp = request.headers.get('X-PacSpace-Timestamp', '')
if not verify_webhook_signature(request.data, signature, timestamp, secret):
return jsonify({'error': 'Invalid signature'}), 401
# Signature verified. Process the event.
event = request.json
print(f"Received {event['event']}: {event['data']['receiptId']}")
return jsonify({'received': True}), 200
Timestamp Validation
In addition to verifying the signature, check the timestamp to prevent replay attacks. Reject any webhook where the timestamp is more than 5 minutes old:
function isTimestampValid(timestamp, toleranceSeconds = 300) {
const webhookTime = parseInt(timestamp, 10);
const currentTime = Math.floor(Date.now() / 1000);
return Math.abs(currentTime - webhookTime) <= toleranceSeconds;
}
import time
def is_timestamp_valid(timestamp: str, tolerance_seconds: int = 300) -> bool:
webhook_time = int(timestamp)
current_time = int(time.time())
return abs(current_time - webhook_time) <= tolerance_seconds
Best Practices
Always verify signatures
Never process a webhook without verifying the signature first. An unverified payload could be spoofed by an attacker.
Check the timestamp
Reject webhooks with timestamps older than 5 minutes to protect against replay attacks.
Use X-Event-ID for idempotency
Network issues can cause duplicate deliveries. Store the X-Event-ID and skip events you've already processed.
async function handleWebhook(req, res) {
const eventId = req.headers['x-event-id'];
// Check if we've already processed this event
if (await isEventProcessed(eventId)) {
return res.status(200).json({ received: true, duplicate: true });
}
// Process and mark as handled
await processEvent(req.body);
await markEventProcessed(eventId);
res.status(200).json({ received: true });
}
Respond quickly, process asynchronously
Return a 200 OK response as soon as the signature is verified. Perform any heavy processing asynchronously. If your endpoint takes too long to respond, PacSpace may retry and create duplicate deliveries.
Use the raw request body
Parse JSON after signature verification. The signature is computed over the raw bytes, so any transformation (pretty-printing, key reordering) will invalidate it.
Next Steps
- Webhook Overview - Setup and configuration
- Payload Reference - What's inside each event
- Autonomous Agent Pattern - Build automated webhook handlers