Skip to content

How to Add an Inbound Webhook Receiver

Receive webhooks from multiple external services, validate HMAC signatures per source, and store events with idempotency.

Schema

sql
CREATE TABLE webhook_sources (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE, secret TEXT NOT NULL,
    active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL
);
CREATE TABLE inbound_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source_id INTEGER NOT NULL REFERENCES webhook_sources(id),
    event_id TEXT NOT NULL, event_type TEXT NOT NULL,
    payload TEXT NOT NULL, processed_at TEXT NOT NULL,
    UNIQUE(source_id, event_id)
);

Routes

MethodPathDescription
POST/sourcesRegister a webhook source
POST/sources/{id}/receiveReceive a webhook
GET/sources/{id}/eventsList received events
GET/events/{id}Get a specific event

HMAC-SHA256 Signature Validation

Each source has its own HMAC secret. Never expose it in responses.

php
private function verifySignature(string $body, string $header, string $secret): bool
{
    if (!str_starts_with($header, 'sha256=')) {
        return false;
    }
    $expected = hash_hmac('sha256', $body, $secret);
    return hash_equals($expected, substr($header, 7)); // timing-safe
}

Call order: validate signature first, then idempotency check, then store:

php
if (!$this->verifySignature($rawBody, $sigHeader, $source['secret'])) {
    return $this->json->create(['error' => 'Invalid signature'], 401);
}
// ... idempotency check ...
$this->repo->storeEvent($sourceId, $eventId, $eventType, $rawBody, $now);

Idempotency (event_id per source)

php
$existing = $this->repo->findEventBySourceAndEventId($sourceId, $eventId);
if ($existing !== null) {
    return $this->json->create(['status' => 'already_processed', 'id' => $existing['id']]);
}

The UNIQUE(source_id, event_id) constraint is the DB-level backstop. The PHP check above avoids the exception path on first duplicate.

Never Expose the Secret

php
$source = $this->repo->findSource($id);
unset($source['secret']); // strip before returning
return $this->json->create($source, 201);

Inactive Source Check

php
if (!(bool) $source['active']) {
    return $this->json->create(['error' => 'Source is inactive'], 403);
}

MySQL Notes

The UNIQUE KEY uq_source_event (source_id, event_id) constraint works the same in MySQL. Use VARCHAR(191) for indexed text columns to stay within InnoDB's key length limit.

Running MySQL Integration Tests

Start the shared FT MySQL container (port 3308, persistent volume):

bash
docker compose -f ../NENE2-FT/docker-compose.yml up -d mysql

Then run the integration tests with environment variables:

bash
MYSQL_HOST=127.0.0.1 MYSQL_PORT=3308 MYSQL_DATABASE=ft_test \
  MYSQL_USER=ft_user MYSQL_PASSWORD=ft_pass \
  php8.4 vendor/bin/phpunit --filter Mysql

Without MYSQL_HOST, the MySQL tests are automatically skipped (markTestSkipped).

Security Notes

  • hash_equals() prevents timing attacks on signature comparison.
  • Raw JSON body is stored as-is; do not parse before signature verification.
  • Same event_id from two different sources creates separate records — the UNIQUE constraint is (source_id, event_id), not just event_id.

Released under the MIT License.