Skip to content

Swappable contracts

Swappable contracts

Every capability is an interface resolved from the container. Depend on the interface, bind your own implementation where the default does not fit.

Contract Default Replace it when
Webhooks WebhookSignatureManager Rarely — this is the front door
SchemeRegistry DefaultSchemeRegistry Schemes come from somewhere other than config
ReplayGuard NullReplayGuard / CacheReplayGuard You want a store other than the cache
Clock SystemClock Testing, or a clock you trust more than the host's

A different replay store

The cache-backed guard covers most cases, but a delivery ledger you already keep in the database is a better source of truth — it survives a cache flush, which a security control arguably should.

use Cbox\WebhookSignature\Contracts\ReplayGuard;

class LedgerReplayGuard implements ReplayGuard
{
    public function firstSeen(string $key, int $ttlSeconds): bool
    {
        // MUST be atomic. Two concurrent deliveries with the same key must not
        // both be told they are new — a unique index does this, a SELECT-then-INSERT
        // does not.
        try {
            DB::table('webhook_deliveries')->insert([
                'key' => hash('sha256', $key),
                'expires_at' => now()->addSeconds($ttlSeconds),
            ]);

            return true;
        } catch (UniqueConstraintViolationException) {
            return false;
        }
    }
}
$this->app->singleton(ReplayGuard::class, LedgerReplayGuard::class);

The atomicity requirement is the whole contract. Providers retry on timeout, so the same delivery genuinely does arrive twice at once; a read followed by a write lets both copies through under exactly the conditions the guard exists for.

A different clock

$this->app->singleton(Clock::class, fn () => new class implements Clock {
    public function now(): int
    {
        return CarbonImmutable::now()->getTimestamp();
    }
});

Useful if your application already centralises time, or if you want verification to use a clock source you trust more than the host's — every timestamp check is a comparison against this, so drift here is drift in the replay window.

A different registry

$this->app->singleton(SchemeRegistry::class, function () {
    $registry = new DefaultSchemeRegistry([new GithubScheme, new StripeScheme]);

    foreach (Partner::query()->cursor() as $partner) {
        $registry->register(HmacScheme::fromConfig($partner->slug, $partner->scheme_definition));
    }

    return $registry;
});

For a platform where partners define their own signature conventions in your database rather than in your config file.

Lookups stay deny-by-default whatever you build: an unknown name throws rather than resolving to something permissive, because verifying traffic under the wrong scheme is worse than an outage.