Skip to content

Webhook Integration Guide

This guide explains how to receive and process webhooks from the core-api package. Learn to verify signatures, handle retries, and implement reliable webhook consumers.

Overview

Webhooks provide real-time notifications when events occur in the system. Instead of polling the API, your application receives HTTP POST requests with event data.

Key Features:

  • HMAC-SHA256 signature verification
  • Automatic retries with exponential backoff
  • Timestamp validation for replay protection
  • Delivery tracking and manual retry

Webhook Payload Format

All webhooks follow a consistent format:

json
{
  "id": "evt_abc123def456789",
  "type": "post.created",
  "created_at": "2026-01-15T10:30:00Z",
  "data": {
    "id": 123,
    "title": "New Blog Post",
    "status": "published",
    "author_id": 42
  },
  "workspace_id": 456
}

Fields:

  • id - Unique event identifier (use for idempotency)
  • type - Event type (e.g., post.created, user.updated)
  • created_at - ISO 8601 timestamp when the event occurred
  • data - Event-specific payload
  • workspace_id - Workspace that generated the event

Webhook Headers

Every webhook request includes these headers:

HeaderDescriptionExample
Content-TypeAlways application/jsonapplication/json
X-Webhook-IdUnique event IDevt_abc123def456
X-Webhook-EventEvent typepost.created
X-Webhook-TimestampUnix timestamp1705312200
X-Webhook-SignatureHMAC-SHA256 signaturea1b2c3d4e5f6...

Signature Verification

Always verify webhook signatures to ensure requests are authentic and unmodified.

Signature Algorithm

The signature is computed as:

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

Where:

  • timestamp is the value of X-Webhook-Timestamp header
  • payload is the raw request body (JSON string)
  • secret is your webhook signing secret

Verification Steps

  1. Get the signature and timestamp from headers
  2. Get the raw request body (do not parse JSON first)
  3. Compute expected signature: HMAC-SHA256(timestamp + "." + body, secret)
  4. Compare signatures using timing-safe comparison
  5. Verify timestamp is within 5 minutes of current time

Code Examples

PHP (Laravel)

php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class WebhookController extends Controller
{
    /**
     * Handle incoming webhooks.
     */
    public function handle(Request $request)
    {
        // Step 1: Verify the signature
        if (!$this->verifySignature($request)) {
            Log::warning('Invalid webhook signature', [
                'ip' => $request->ip(),
            ]);
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        // Step 2: Verify timestamp (replay protection)
        if (!$this->verifyTimestamp($request)) {
            Log::warning('Webhook timestamp too old');
            return response()->json(['error' => 'Timestamp expired'], 401);
        }

        // Step 3: Check for duplicate events (idempotency)
        $eventId = $request->input('id');
        if ($this->isDuplicate($eventId)) {
            // Already processed - return success to stop retries
            return response()->json(['received' => true]);
        }

        // Step 4: Process the event
        try {
            $this->processEvent(
                $request->input('type'),
                $request->input('data')
            );

            // Mark event as processed
            $this->markProcessed($eventId);

            return response()->json(['received' => true]);

        } catch (\Exception $e) {
            Log::error('Webhook processing failed', [
                'event_id' => $eventId,
                'error' => $e->getMessage(),
            ]);

            // Return 500 to trigger retry
            return response()->json(['error' => 'Processing failed'], 500);
        }
    }

    /**
     * Verify the HMAC-SHA256 signature.
     */
    protected function verifySignature(Request $request): bool
    {
        $signature = $request->header('X-Webhook-Signature');
        $timestamp = $request->header('X-Webhook-Timestamp');
        $payload = $request->getContent();
        $secret = config('services.webhooks.secret');

        if (!$signature || !$timestamp) {
            return false;
        }

        // Compute expected signature
        $signedPayload = $timestamp . '.' . $payload;
        $expectedSignature = hash_hmac('sha256', $signedPayload, $secret);

        // Use timing-safe comparison
        return hash_equals($expectedSignature, $signature);
    }

    /**
     * Verify timestamp is within tolerance (5 minutes).
     */
    protected function verifyTimestamp(Request $request): bool
    {
        $timestamp = (int) $request->header('X-Webhook-Timestamp');
        $tolerance = 300; // 5 minutes

        return abs(time() - $timestamp) <= $tolerance;
    }

    /**
     * Check if event was already processed.
     */
    protected function isDuplicate(string $eventId): bool
    {
        return cache()->has("webhook:processed:{$eventId}");
    }

    /**
     * Mark event as processed (cache for 24 hours).
     */
    protected function markProcessed(string $eventId): void
    {
        cache()->put("webhook:processed:{$eventId}", true, now()->addDay());
    }

    /**
     * Process the webhook event.
     */
    protected function processEvent(string $type, array $data): void
    {
        match ($type) {
            'post.created' => $this->handlePostCreated($data),
            'post.updated' => $this->handlePostUpdated($data),
            'post.deleted' => $this->handlePostDeleted($data),
            'user.created' => $this->handleUserCreated($data),
            default => Log::info("Unhandled webhook type: {$type}"),
        };
    }

    protected function handlePostCreated(array $data): void
    {
        // Sync to your database, trigger notifications, etc.
        Log::info('Post created', $data);
    }

    protected function handlePostUpdated(array $data): void
    {
        Log::info('Post updated', $data);
    }

    protected function handlePostDeleted(array $data): void
    {
        Log::info('Post deleted', $data);
    }

    protected function handleUserCreated(array $data): void
    {
        Log::info('User created', $data);
    }
}

Route registration:

php
// routes/api.php
Route::post('/webhooks', [WebhookController::class, 'handle'])
    ->middleware('throttle:100,1'); // Rate limit webhook endpoint

JavaScript (Node.js/Express)

javascript
const express = require('express');
const crypto = require('crypto');
const app = express();

// Important: Use raw body for signature verification
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const payload = req.body; // Raw buffer
  const secret = process.env.WEBHOOK_SECRET;

  // Step 1: Verify signature
  if (!verifySignature(payload, signature, timestamp, secret)) {
    console.warn('Invalid webhook signature');
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Step 2: Verify timestamp
  if (!verifyTimestamp(timestamp)) {
    console.warn('Webhook timestamp too old');
    return res.status(401).json({ error: 'Timestamp expired' });
  }

  // Step 3: Parse the event
  let event;
  try {
    event = JSON.parse(payload.toString());
  } catch (e) {
    return res.status(400).json({ error: 'Invalid JSON' });
  }

  // Step 4: Check for duplicates
  if (await isDuplicate(event.id)) {
    return res.json({ received: true });
  }

  // Step 5: Process the event
  try {
    await processEvent(event.type, event.data);
    await markProcessed(event.id);
    res.json({ received: true });
  } catch (e) {
    console.error('Webhook processing failed:', e);
    res.status(500).json({ error: 'Processing failed' });
  }
});

function verifySignature(payload, signature, timestamp, secret) {
  if (!signature || !timestamp) return false;

  const signedPayload = timestamp + '.' + payload.toString();
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Timing-safe comparison
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  } catch {
    return false;
  }
}

function verifyTimestamp(timestamp) {
  const tolerance = 300; // 5 minutes
  const now = Math.floor(Date.now() / 1000);
  return Math.abs(now - parseInt(timestamp)) <= tolerance;
}

// Redis-based duplicate detection
const Redis = require('ioredis');
const redis = new Redis();

async function isDuplicate(eventId) {
  return await redis.exists(`webhook:processed:${eventId}`);
}

async function markProcessed(eventId) {
  await redis.set(`webhook:processed:${eventId}`, '1', 'EX', 86400);
}

async function processEvent(type, data) {
  switch (type) {
    case 'post.created':
      console.log('Post created:', data);
      break;
    case 'post.updated':
      console.log('Post updated:', data);
      break;
    case 'post.deleted':
      console.log('Post deleted:', data);
      break;
    default:
      console.log(`Unhandled event type: ${type}`);
  }
}

app.listen(3000);

Python (Flask)

python
import hmac
import hashlib
import time
import json
from functools import wraps
from flask import Flask, request, jsonify
import redis

app = Flask(__name__)
cache = redis.Redis()

WEBHOOK_SECRET = 'your_webhook_secret'
TIMESTAMP_TOLERANCE = 300  # 5 minutes

def verify_webhook(f):
    """Decorator to verify webhook signatures."""
    @wraps(f)
    def decorated(*args, **kwargs):
        signature = request.headers.get('X-Webhook-Signature')
        timestamp = request.headers.get('X-Webhook-Timestamp')
        payload = request.get_data()

        # Verify signature
        if not verify_signature(payload, signature, timestamp):
            return jsonify({'error': 'Invalid signature'}), 401

        # Verify timestamp
        if not verify_timestamp(timestamp):
            return jsonify({'error': 'Timestamp expired'}), 401

        return f(*args, **kwargs)
    return decorated


def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool:
    """Verify the HMAC-SHA256 signature."""
    if not signature or not timestamp:
        return False

    signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    return hmac.compare_digest(expected_signature, signature)


def verify_timestamp(timestamp: str) -> bool:
    """Verify timestamp is within tolerance."""
    try:
        ts = int(timestamp)
        return abs(time.time() - ts) <= TIMESTAMP_TOLERANCE
    except (ValueError, TypeError):
        return False


def is_duplicate(event_id: str) -> bool:
    """Check if event was already processed."""
    return cache.exists(f"webhook:processed:{event_id}")


def mark_processed(event_id: str) -> None:
    """Mark event as processed (24 hour TTL)."""
    cache.setex(f"webhook:processed:{event_id}", 86400, "1")


@app.route('/webhooks', methods=['POST'])
@verify_webhook
def handle_webhook():
    event = request.get_json()
    event_id = event.get('id')
    event_type = event.get('type')
    data = event.get('data')

    # Check for duplicates
    if is_duplicate(event_id):
        return jsonify({'received': True})

    # Process the event
    try:
        process_event(event_type, data)
        mark_processed(event_id)
        return jsonify({'received': True})
    except Exception as e:
        app.logger.error(f"Webhook processing failed: {e}")
        return jsonify({'error': 'Processing failed'}), 500


def process_event(event_type: str, data: dict) -> None:
    """Process webhook event based on type."""
    handlers = {
        'post.created': handle_post_created,
        'post.updated': handle_post_updated,
        'post.deleted': handle_post_deleted,
        'user.created': handle_user_created,
    }

    handler = handlers.get(event_type)
    if handler:
        handler(data)
    else:
        app.logger.info(f"Unhandled event type: {event_type}")


def handle_post_created(data: dict) -> None:
    app.logger.info(f"Post created: {data}")


def handle_post_updated(data: dict) -> None:
    app.logger.info(f"Post updated: {data}")


def handle_post_deleted(data: dict) -> None:
    app.logger.info(f"Post deleted: {data}")


def handle_user_created(data: dict) -> None:
    app.logger.info(f"User created: {data}")


if __name__ == '__main__':
    app.run(port=3000)

Retry Handling

Retry Schedule

Failed webhook deliveries are automatically retried with exponential backoff:

AttemptDelayTotal Time
1Immediate0
21 minute1 minute
35 minutes6 minutes
430 minutes36 minutes
52 hours2h 36m
6 (final)24 hours26h 36m

After 6 failed attempts, the delivery is marked as permanently failed.

Triggering Retries

A delivery is retried when your endpoint returns:

  • 5xx status codes (server errors)
  • Connection timeouts (30 second default)
  • Connection refused/failed

A delivery is not retried when:

  • 2xx status codes (success)
  • 4xx status codes (client errors - your endpoint rejected it)

Best Practices for Reliability

1. Return 200 Quickly

Process webhooks asynchronously to avoid timeouts:

php
public function handle(Request $request)
{
    // Verify signature first
    if (!$this->verifySignature($request)) {
        return response()->json(['error' => 'Invalid signature'], 401);
    }

    // Queue for async processing
    ProcessWebhook::dispatch($request->all());

    // Return immediately
    return response()->json(['received' => true]);
}

2. Handle Duplicates

Webhooks may be delivered more than once. Always check the event ID:

php
public function handle(Request $request)
{
    $eventId = $request->input('id');

    // Atomic check-and-set
    if (!Cache::add("webhook:{$eventId}", true, now()->addDay())) {
        // Already processed
        return response()->json(['received' => true]);
    }

    // Process the event...
}

3. Return 4xx for Permanent Failures

If your endpoint cannot process an event (invalid data, etc.), return 4xx to stop retries:

php
public function handle(Request $request)
{
    $eventType = $request->input('type');

    // Unknown event type - don't retry
    if (!in_array($eventType, $this->supportedEvents)) {
        return response()->json(['error' => 'Unknown event type'], 400);
    }

    // Process...
}

Event Types

Common Events

EventDescription
{resource}.createdResource was created
{resource}.updatedResource was updated
{resource}.deletedResource was deleted
{resource}.publishedResource was published
{resource}.archivedResource was archived

Wildcard Subscriptions

Subscribe to all events for a resource:

php
$webhook = WebhookEndpoint::create([
    'url' => 'https://your-app.com/webhooks',
    'events' => ['post.*'], // All post events
    'secret' => 'whsec_' . Str::random(32),
]);

Subscribe to all events:

php
$webhook = WebhookEndpoint::create([
    'url' => 'https://your-app.com/webhooks',
    'events' => ['*'], // All events
    'secret' => 'whsec_' . Str::random(32),
]);

High-Volume Events

Some events are high-volume and opt-in only:

  • link.clicked - Link click tracking
  • qrcode.scanned - QR code scan tracking

These must be explicitly included in the events array.

Testing Webhooks

Test Endpoint

Use the test endpoint to verify your webhook handler:

bash
curl -X POST https://api.example.com/v1/webhooks/{webhook_id}/test \
  -H "Authorization: Bearer sk_live_abc123"

This sends a test event to your endpoint.

Local Development

For local development, use a tunnel service:

ngrok:

bash
ngrok http 3000
# Use the https URL as your webhook endpoint

Cloudflare Tunnel:

bash
cloudflared tunnel --url http://localhost:3000

Mock Verification

Test signature verification in isolation:

php
// tests/Feature/WebhookTest.php
public function test_verifies_valid_signature(): void
{
    $payload = json_encode([
        'id' => 'evt_test123',
        'type' => 'post.created',
        'data' => ['id' => 1, 'title' => 'Test'],
    ]);

    $timestamp = time();
    $secret = 'test_secret';
    $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);

    config(['services.webhooks.secret' => $secret]);

    $response = $this->postJson('/webhooks', json_decode($payload, true), [
        'X-Webhook-Signature' => $signature,
        'X-Webhook-Timestamp' => $timestamp,
        'Content-Type' => 'application/json',
    ]);

    $response->assertOk();
}

public function test_rejects_invalid_signature(): void
{
    $response = $this->postJson('/webhooks', [
        'id' => 'evt_test123',
        'type' => 'post.created',
    ], [
        'X-Webhook-Signature' => 'invalid',
        'X-Webhook-Timestamp' => time(),
    ]);

    $response->assertUnauthorized();
}

Troubleshooting

Signature Verification Fails

Common causes:

  1. Parsed JSON instead of raw body

    php
    // Wrong - body has been modified
    $payload = json_encode($request->all());
    
    // Correct - raw body
    $payload = $request->getContent();
  2. Different secrets

    • Check the secret matches exactly
    • Ensure no extra whitespace
  3. Encoding issues

    php
    // Ensure UTF-8 encoding
    $payload = $request->getContent();
    $signedPayload = $timestamp . '.' . $payload;

Deliveries Not Arriving

  1. Check endpoint URL - Must be publicly accessible (not localhost)
  2. Check SSL certificate - Must be valid and not expired
  3. Check firewall rules - Allow incoming HTTPS from webhook IPs
  4. Check webhook is active - Endpoints can be disabled after failures

Timeouts

The default timeout is 30 seconds. If processing takes longer:

php
// Queue long-running tasks
public function handle(Request $request)
{
    // Quick signature check
    if (!$this->verifySignature($request)) {
        return response()->json(['error' => 'Invalid signature'], 401);
    }

    // Queue for async processing
    ProcessWebhook::dispatch($request->all());

    // Return immediately
    return response()->json(['received' => true]);
}

Security Considerations

Always Verify Signatures

Never skip signature verification, even in development:

php
// DON'T DO THIS
if (app()->environment('local')) {
    return; // Skip verification
}

Use HTTPS

Webhook endpoints must use HTTPS to protect:

  • The webhook secret in transit
  • Sensitive payload data

Protect Your Secret

  • Store in environment variables, not code
  • Rotate secrets periodically
  • Use different secrets per environment

Rate Limit Your Endpoint

Protect against abuse:

php
Route::post('/webhooks', [WebhookController::class, 'handle'])
    ->middleware('throttle:100,1'); // 100 requests per minute

Learn More

Released under the EUPL-1.2 License.