Skip to content

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:

HeaderDescriptionExample
X-PacSpace-SignatureHMAC-SHA256 signature of the payloadv1=5a2f8c...
X-PacSpace-TimestampUnix timestamp (seconds) of when the webhook was sent1739270400
X-Webhook-EventThe event typedelta.verified
X-Event-IDUnique 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:

  • timestamp is the value of X-PacSpace-Timestamp
  • payload is the raw JSON request body (exactly as sent, with no modifications)

Verification Algorithm

JavaScript

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:

javascript
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

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:

python
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:

javascript
function isTimestampValid(timestamp, toleranceSeconds = 300) {
  const webhookTime = parseInt(timestamp, 10);
  const currentTime = Math.floor(Date.now() / 1000);
  return Math.abs(currentTime - webhookTime) <= toleranceSeconds;
}
python
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.

javascript
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

Was this page helpful?

Last updated February 11, 2026