Webhooks
Signature Verification
Verify webhook authenticity using HMAC-SHA256 signatures.
Every webhook PacSpace sends is signed with your webhook secret. Always verify the signature before processing the payload to ensure it came from PacSpace and hasn't been tampered with.
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
JavaScript
const crypto = require('crypto');
function verifyWebhookSignature(request, secret) {
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
// 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);
const received = Buffer.from(signature);
if (expected.length !== received.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
Express.js example:
const express = require('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:
# 1. Reconstruct the signed content
signed_content = f"{timestamp}.{payload.decode('utf-8')}"
# 2. Compute HMAC-SHA256 with your webhook secret
expected = 'v1=' + hmac.new(
secret.encode('utf-8'),
signed_content.encode('utf-8'),
hashlib.sha256
).hexdigest()
# 3. Constant-time comparison
return hmac.compare_digest(expected, signature)
Flask example:
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 (database writes, external API calls, notifications) asynchronously. If your endpoint takes too long to respond, PacSpace may retry — causing 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
Last updated February 11, 2026