Skip to content

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:

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

TypeScript

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:

typescript
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

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:

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

typescript
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 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