# Webhook Examples

## Signature verification

### C# / .NET


```csharp
using System.Security.Cryptography;
using System.Text;

public sealed class WebhookSignatureVerifier
{
    private readonly byte[] _secretKey;
    private readonly TimeSpan _timestampTolerance;

    public WebhookSignatureVerifier(string secret, int timestampToleranceSeconds = 300)
    {
        _secretKey = Convert.FromBase64String(secret.Replace("whsec_", ""));
        _timestampTolerance = TimeSpan.FromSeconds(timestampToleranceSeconds);
    }

    public bool Verify(string webhookId, string timestamp, string body, string signature)
    {
        if (!long.TryParse(timestamp, out var unixSeconds))
            return false;

        var webhookTime = DateTimeOffset.FromUnixTimeSeconds(unixSeconds);
        if (DateTimeOffset.UtcNow - webhookTime > _timestampTolerance)
            return false;

        var toSign = $"{webhookId}.{timestamp}.{body}";
        var hash = HMACSHA256.HashData(_secretKey, Encoding.UTF8.GetBytes(toSign));
        var expected = $"v1,{Convert.ToBase64String(hash)}";

        // The signature header may contain multiple signatures separated by spaces.
        // Check each candidate using constant-time comparison.
        foreach (var candidate in signature.Split(' '))
        {
            var candidateBytes = Encoding.UTF8.GetBytes(candidate.Trim());
            var expectedBytes = Encoding.UTF8.GetBytes(expected);

            if (candidateBytes.Length == expectedBytes.Length
                && CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes))
                return true;
        }

        return false;
    }
}
```

### PHP


```php
function verifyWebhookSignature(
    string $webhookId,
    string $timestamp,
    string $body,
    string $signature,
    string $secret,
    int $toleranceSeconds = 300
): bool {
    // Check timestamp tolerance
    $webhookTime = (int) $timestamp;
    if (abs(time() - $webhookTime) > $toleranceSeconds) {
        return false;
    }

    // Compute expected signature
    $key = base64_decode(str_replace('whsec_', '', $secret));
    $toSign = "{$webhookId}.{$timestamp}.{$body}";
    $hash = hash_hmac('sha256', $toSign, $key, true);
    $expected = 'v1,' . base64_encode($hash);

    // Check each candidate (signatures may be space-separated)
    foreach (explode(' ', $signature) as $candidate) {
        if (hash_equals($expected, trim($candidate))) {
            return true;
        }
    }

    return false;
}
```

### JavaScript / Node.js


```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhookSignature(webhookId, timestamp, body, signature, secret, toleranceSeconds = 300) {
  // Check timestamp tolerance
  const webhookTime = parseInt(timestamp, 10);
  if (Math.abs(Math.floor(Date.now() / 1000) - webhookTime) > toleranceSeconds) {
    return false;
  }

  // Compute expected signature
  const key = Buffer.from(secret.replace('whsec_', ''), 'base64');
  const toSign = `${webhookId}.${timestamp}.${body}`;
  const hash = createHmac('sha256', key).update(toSign).digest('base64');
  const expected = `v1,${hash}`;

  // Check each candidate (signatures may be space-separated)
  for (const candidate of signature.split(' ')) {
    const candidateBuf = Buffer.from(candidate.trim());
    const expectedBuf = Buffer.from(expected);

    if (candidateBuf.length === expectedBuf.length && timingSafeEqual(candidateBuf, expectedBuf)) {
      return true;
    }
  }

  return false;
}
```

## Webhook receiver

A minimal receiver that verifies the signature, acknowledges immediately, and processes asynchronously.

### ASP.NET Core (Minimal API)


```csharp
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/webhooks/apiera", async (HttpContext context) =>
{
    context.Request.EnableBuffering();

    using var reader = new StreamReader(context.Request.Body);
    var body = await reader.ReadToEndAsync();

    var webhookId = context.Request.Headers["webhook-id"].ToString();
    var timestamp = context.Request.Headers["webhook-timestamp"].ToString();
    var signature = context.Request.Headers["webhook-signature"].ToString();
    var eventType = context.Request.Headers["X-Webhook-Event-Type"].ToString();

    if (string.IsNullOrEmpty(webhookId) || string.IsNullOrEmpty(timestamp)
        || string.IsNullOrEmpty(signature) || string.IsNullOrEmpty(eventType))
    {
        return Results.BadRequest("Missing required webhook headers");
    }

    var verifier = new WebhookSignatureVerifier(builder.Configuration["Webhook:Secret"]!);

    if (!verifier.Verify(webhookId, timestamp, body, signature))
        return Results.Unauthorized();

    // Queue for background processing, respond immediately
    using var payload = JsonDocument.Parse(body);
    _ = Task.Run(() => ProcessWebhookAsync(eventType, payload));

    return Results.Ok(new { status = "accepted" });
});

app.Run();
```

### PHP (Laravel)


```php
Route::post('/webhooks/apiera', function (Request $request) {
    $webhookId = $request->header('webhook-id');
    $timestamp = $request->header('webhook-timestamp');
    $signature = $request->header('webhook-signature');
    $eventType = $request->header('X-Webhook-Event-Type');
    $body = $request->getContent();

    if (!$webhookId || !$timestamp || !$signature || !$eventType) {
        return response('Missing required headers', 400);
    }

    $secret = config('services.apiera.webhook_secret');

    if (!verifyWebhookSignature($webhookId, $timestamp, $body, $signature, $secret)) {
        return response('Invalid signature', 401);
    }

    // Dispatch to background job, respond immediately
    ProcessApieraWebhook::dispatch($eventType, $request->json()->all(), $webhookId);

    return response()->json(['status' => 'accepted']);
});
```

### JavaScript (Express)


```javascript
import express from 'express';

const app = express();
app.use(express.raw({ type: 'application/json' }));

app.post('/webhooks/apiera', (req, res) => {
  const webhookId = req.headers['webhook-id'];
  const timestamp = req.headers['webhook-timestamp'];
  const signature = req.headers['webhook-signature'];
  const eventType = req.headers['x-webhook-event-type'];
  const body = req.body.toString();

  if (!webhookId || !timestamp || !signature || !eventType) {
    return res.status(400).json({ error: 'Missing required headers' });
  }

  if (!verifyWebhookSignature(webhookId, timestamp, body, signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(body);

  // Process asynchronously, respond immediately
  setImmediate(() => processWebhook(eventType, payload, webhookId));

  res.json({ status: 'accepted' });
});
```

## Coalescing scheduler

The recommended pattern for sync-style integrations. This reference implementation is based on Apiera's production
WooCommerce connector. See [Event Granularity](/webhooks/event-granularity) for the concepts behind this pattern.

### C# / .NET


```csharp
using System.Collections.Concurrent;

/// <summary>
/// Coalesces webhook signals for the same entity into minimal API fetches.
/// Debounces: waits for a burst of events to settle before syncing.
/// Coalesces: events arriving during sync trigger one re-sync, not many.
/// Register as a singleton and IHostedService.
/// </summary>
public sealed class CoalescingSyncScheduler : IHostedService, IDisposable
{
    private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(2);

    private readonly Func<Guid, CancellationToken, Task> _syncFn;
    private readonly ILogger _logger;
    private readonly Dictionary<Guid, DebounceEntry> _debouncing = new();
    private readonly HashSet<Guid> _active = [];
    private readonly HashSet<Guid> _pending = [];
    private readonly Lock _lock = new();
    private readonly CancellationTokenSource _shutdownCts = new();
    private long _debounceIdCounter;

    public CoalescingSyncScheduler(
        Func<Guid, CancellationToken, Task> syncFn,
        ILogger<CoalescingSyncScheduler> logger)
    {
        _syncFn = syncFn;
        _logger = logger;
    }

    /// <summary>
    /// Called by your webhook handler for each incoming event.
    /// Multiple calls for the same entity are coalesced automatically.
    /// </summary>
    public void ScheduleSync(Guid entityUuid)
    {
        lock (_lock)
        {
            // If sync is already in progress, just mark as needing re-sync
            if (_active.Contains(entityUuid))
            {
                _pending.Add(entityUuid);
                return;
            }

            // Cancel existing debounce timer and start a new one
            if (_debouncing.TryGetValue(entityUuid, out var existing))
            {
                existing.Cts.Cancel();
                existing.Cts.Dispose();
            }

            var debounceId = ++_debounceIdCounter;
            var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdownCts.Token);
            _debouncing[entityUuid] = new DebounceEntry(debounceId, cts);

            _ = DelayThenSyncAsync(entityUuid, debounceId, cts.Token);
        }
    }

    private async Task DelayThenSyncAsync(
        Guid entityUuid, long debounceId, CancellationToken cancellationToken)
    {
        try
        {
            await Task.Delay(DebounceWindow, cancellationToken);
        }
        catch (OperationCanceledException)
        {
            return; // Debounce was reset by a newer event
        }

        lock (_lock)
        {
            // Ensure this is still the active debounce (not superseded)
            if (!_debouncing.TryGetValue(entityUuid, out var entry) || entry.Id != debounceId)
                return;

            _debouncing.Remove(entityUuid);
            entry.Cts.Dispose();
            _active.Add(entityUuid);
        }

        await ExecuteSyncLoopAsync(entityUuid);
    }

    private async Task ExecuteSyncLoopAsync(Guid entityUuid)
    {
        while (true)
        {
            try
            {
                await _syncFn(entityUuid, _shutdownCts.Token);
            }
            catch (OperationCanceledException)
            {
                lock (_lock)
                {
                    _active.Remove(entityUuid);
                    _pending.Remove(entityUuid);
                }
                return;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Failed to sync entity {EntityUuid}", entityUuid);
            }

            lock (_lock)
            {
                if (_pending.Remove(entityUuid))
                    continue; // Events arrived during sync, go again

                _active.Remove(entityUuid);
            }

            return; // No pending events, done
        }
    }

    public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _shutdownCts.Cancel();

        lock (_lock)
        {
            foreach (var (_, entry) in _debouncing)
                entry.Cts.Dispose();

            _debouncing.Clear();
            _active.Clear();
            _pending.Clear();
        }

        return Task.CompletedTask;
    }

    public void Dispose() => _shutdownCts.Dispose();

    private sealed record DebounceEntry(long Id, CancellationTokenSource Cts);
}
```

### Wiring it up

Register the scheduler as a singleton and hosted service, then call `ScheduleSync` from your webhook handler:


```csharp
// Program.cs / DI registration
builder.Services.AddSingleton<CoalescingSyncScheduler>(sp =>
    new CoalescingSyncScheduler(
        syncFn: async (entityUuid, ct) =>
        {
            await using var scope = sp.GetRequiredService<IServiceScopeFactory>().CreateAsyncScope();
            var syncer = scope.ServiceProvider.GetRequiredService<IProductSyncer>();
            await syncer.SyncAsync(entityUuid, ct);
        },
        logger: sp.GetRequiredService<ILogger<CoalescingSyncScheduler>>()));

builder.Services.AddHostedService(sp => sp.GetRequiredService<CoalescingSyncScheduler>());
```


```csharp
// Webhook endpoint
app.MapPost("/webhooks/apiera", async (HttpContext context, CoalescingSyncScheduler scheduler) =>
{
    // ... verify signature ...

    var eventType = context.Request.Headers["X-Webhook-Event-Type"].ToString();

    if (eventType.StartsWith("product."))
    {
        using var payload = JsonDocument.Parse(body);
        var productUuid = payload.RootElement.GetProperty("productUuid").GetGuid();
        scheduler.ScheduleSync(productUuid);
    }

    return Results.Ok(new { status = "accepted" });
});
```

This pattern works for any resource type. Extract the relevant UUID from the payload and pass it to the scheduler.
Multiple event types for the same entity (e.g. `product.updated`, `product.attribute.linked`,
`product.tag.linked`) are collapsed into a single sync.