Skip to content

Custom schemes

Custom schemes

In configuration

Most providers do the same three things in a slightly different order: pick a header, decide what goes into the signed string, pick a digest and an encoding. The generic HMAC driver takes those as parameters, so integrating a provider we have never heard of is a config entry rather than a pull request and a release wait.

// config/webhook-signature.php
'schemes' => [
    'acme' => [
        'header'           => 'X-Acme-Signature',
        'prefix'           => 'sha256=',
        'payload'          => '{timestamp}.{body}',
        'digest'           => 'sha256',
        'encoding'         => 'hex',
        'timestamp_header' => 'X-Acme-Timestamp',
        'tolerance'        => 300,
    ],
],

'endpoints' => [
    'acme' => ['scheme' => 'acme', 'secrets' => [env('ACME_WEBHOOK_SECRET')]],
],
Key Required Meaning
header yes Header carrying the signature
payload no Template over {body}, {timestamp}, {id}. Must contain {body}. Default {body}
digest no sha1, sha256 (default), sha512
encoding no hex (default) or base64
prefix no Literal prefix on the wire, e.g. sha256=
timestamp_header when {timestamp} is used Where to read the timestamp
id_header when {id} is used Where to read the message id
tolerance no Seconds; ignored when the template binds no timestamp

Definitions are validated, not defaulted

A definition the driver cannot honour exactly is refused. A misspelled digest that quietly fell back to SHA-256 would look like a working configuration while verifying something other than what you specified — so it throws ConfigurationError instead, at boot, naming the problem.

The same applies to {timestamp} without a timestamp_header: the template says the provider binds a time, and the definition does not say where to find it, so it is rejected rather than silently ignored.

A template with no {timestamp} reports a null tolerance whatever you configured — there is nothing signed to check a window against, and advertising one would be a lie.

In code

When the canonical string is not expressible as a template — Twilio's sorted parameters, Mailgun's in-body signature — write a class. Extend Scheme for the shared refusals and implement SignatureScheme, plus SignsWebhooks if the format can be produced from headers.

namespace App\Webhooks;

use Cbox\WebhookSignature\Contracts\SignatureScheme;
use Cbox\WebhookSignature\Enums\{Digest, Encoding, FailureReason};
use Cbox\WebhookSignature\Schemes\Scheme;
use Cbox\WebhookSignature\Support\Hmac;
use Cbox\WebhookSignature\ValueObjects\{Secret, SecretSet, VerificationContext, VerifiedWebhook, WebhookMessage};

class AcmeScheme extends Scheme implements SignatureScheme
{
    public function name(): string
    {
        return 'acme';
    }

    public function defaultTolerance(): ?int
    {
        return 300;
    }

    public function verify(WebhookMessage $message, SecretSet $secrets, VerificationContext $context): VerifiedWebhook
    {
        $this->requireSecrets($secrets);

        $presented = $this->requireWellFormed(
            $this->requireHeader($message, 'X-Acme-Signature'),
            Encoding::Hex,
            Digest::Sha256,
        );

        $matched = Hmac::match(
            $secrets,
            fn (Secret $secret): string => Hmac::compute(
                $this->canonicalize($message),
                $secret->value,
                Digest::Sha256,
                Encoding::Hex,
            ),
            [$presented],
        );

        if ($matched === null) {
            $this->refuse(FailureReason::SignatureMismatch);
        }

        return new VerifiedWebhook(
            scheme: $this->name(),
            body: $message->body,
            signature: $presented,
            secretId: $matched->id,
        );
    }
}

Register it from a service provider's boot():

Webhooks::extend(new AcmeScheme);

Registration is last-write-wins, so this also lets you replace a bundled scheme in place if a provider changes its convention before we ship the update — no fork required.

Rules for a scheme

  • Deny by default. Throw for anything not provably authentic. Never return a partially-trusted result.
  • Compute the MAC through Hmac. Do not call hash_hmac directly, and never compare with === — the constant-time comparison and the no-early-exit loop over secrets are properties of that class.
  • Report an honest defaultTolerance(). Return null if the provider binds no timestamp. Claiming a window you cannot enforce is worse than having none.
  • Surface an event id when the provider sends one. It is what makes single-use enforcement work.