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 callhash_hmacdirectly, 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.