Receiving (Client layer)
Turn on client.enabled, declare one entry per producer under client.configs, and route to
it with Route::webhooks($url, $name = null, $verb = 'post') — from routes/api.php, not
routes/web.php. The macro binds a named route (webhooks.{name}) and pins the config name
onto it.
Laravel's web group carries forgery protection. A producer has no session and no token, so the
request is rejected before the controller runs — before the signature is verified, before
anything is logged, before any event fires. The status is what makes it permanent rather than
noisy: most producers treat 4xx as final and stop retrying, so the deliveries are gone rather
than delayed, and from inside the application everything looks correct. webhooks:preflight
fails on it and names the route.
You can also drive the controller-less processor directly:
use Pushery\Webhooks\Client\WebhookProcessor;
use Pushery\Webhooks\Client\WebhookConfig;
$response = new WebhookProcessor($request, WebhookConfig::forName('partner'))->process();
The pipeline
In order: capture the exact raw bytes → verify the signature → throttle the source → de-duplicate → filter → decode the body → store → dispatch the handler job.
-
Verification. An invalid, expired or malformed signature responds with
invalid_status(401by default) — never500, because a request that can never verify must not tell the sender to retry. Each failure fires aPushery\Webhooks\Client\Events\InvalidWebhookSignature. -
Replay protection. For a dialect that signs a timestamp, it is checked against
tolerance_seconds(default300). Two built-in adapters sign none — see the Replay window column in Built-in receive adapters — and for thosetolerance_secondsis never consulted, so those two sources have no replay protection at all, and nothing you can set here gives them any.webhooks:preflightsays so per source.The
dedupe_idbelow is often mistaken for one, including in this page's own earlier wording. It is not: on a body-only dialect the header it reads is not covered by the signature, so the sender chooses the key. It separates a real producer's honest retries — which is worth having — and against somebody replaying a delivery they captured it holds nothing, because they can simply write a different id. That is retry dedupe. Only a signed timestamp bounds a replay, and a dialect either has one or it does not. -
Idempotency. Two-tier dedupe on the producer's delivery id: a cache fast path in front of a partial-unique insert, so an at-least-once sender (including this package's own Server on retry) is never processed twice. A call with no id is always stored. By default the id is read from the
webhook-idheader; providers that carry no delivery-id header (Stripe'sevt_…is in the body, Mollie'str_…is a form field, SendCloud sends none) setdedupe_idto read it from elsewhere —'header:X-Delivery-Id','body:id'(a dotted path into the decoded body, whichever format it arrived in), or aPushery\Webhooks\Client\Dedupe\DedupeKeyResolverclass. Without it the key stays null, a null collides with nothing, and dedupe silently does nothing for exactly those providers.The delivery id, not the id of the thing it is aboutStripe's
data.object.idis the invoice or the charge — every event about that object carries it. Keying dedupe on it makesinvoice.payment_faileda duplicate of theinvoice.paidthat came before it, and the second delivery is acknowledged and dropped without a trace. Stripe's delivery id is the envelope's ownid(evt_…), which is why the path above isbody:id. -
Event type. The column a stream is split on, and what
processroutes by. By default it is the body's owntypefield, which is what a Standard Webhooks producer sends. Producers that name their events elsewhere needevent_type, with the same grammar asdedupe_id—'header:X-GitHub-Event','body:data.kind', or aPushery\Webhooks\Client\EventTypeResolverclass. GitHub is the case to know: it sends the type inX-GitHub-Eventand leaves the body carrying onlyaction, so without this every delivery is logged with an empty type, the generated column is empty too, and per-type routing falls to'*'every time. Nothing goes red — receiving, dedupe and the payload all work; only the column you would split on is blank. The resolver form is the one GitHub actually wants, because the useful type is the header andactiontogether (release.published), which neither half gives you alone. -
Secret rotation.
previous_secretkeeps the producer's old secret verifying while a rotation is in flight. Without it a rotation is an outage: the producer switches, and every delivery signed with the new secret is refused401until your deploy lands — with the producer's retries burning down in between. Set it to the old value for the length of the window, then remove it. -
A config that cannot verify at all. Every entry needs one of
secret,jwksorverifier. An entry with none of the three is refused with itsinvalid_status— the same answer a rejected signature gets, because it is the same fact about the request — and the fault is logged as a configuration error naming the config.Find it before a producer doesNothing about this is visible from your own application: every page renders, no check is red, and the first symptom is a delivery you never received. Run
webhooks:preflighton deploy, or wirePushery\Webhooks\Client\WebhookConfig::configurationFaults()into your health check — it returns one message per faulty entry, and an empty array when there are none. -
Raw-body capture. A prepended middleware preserves the exact bytes the signature was computed over, before any body parsing.
-
Body decoding. JSON, or a form body the producer declared as
application/x-www-form-urlencoded— see Not every producer sends JSON for what happens when neither reads. -
Event routing.
processis either a singleProcessWebhookJobsubclass or an['event.type' => JobClass, '*' => FallbackJob]map. The job receives the stored call and the parsed envelope ($this->message). -
Rate limiting.
rate_limitis a per-source token bucket with two keys —rate_limit.max_attempts(how many requests) overrate_limit.decay_seconds(the window) — and it is the only brake this package brings. It sits after verification, on purpose, so a forged request cannot exhaust a real producer's bucket — which also means it is not a brake on the verification itself. Nothing this package registers throttles a request before then, and for ajwkssource verification is an outbound call to the provider; see Before verification, nothing here throttles. Omit the key, or leave itnull, and the source is received without limit; that is the default. An over-limit request is answered429withRetry-Afterand is neither stored nor dispatched, so a bucket set too tight drops authentic traffic silently rather than deferring it. Neither a forged request nor a replayed one counts against the bucket: verification runs before the check, and a delivery the dedupe recognizes as one already taken spends no token. The bucket is per source rather than per sender, so that second half matters — a captured authentic delivery verifies correctly, and on a dialect that signs no timestamp it never expires, so charging for repeats would have let anyone who saw one delivery empty the producer's bucket. A delivery your profile filters out still counts: it is a distinct one the producer sent. -
Offloading a large body.
large_payload.enabledmoves a body overlarge_payload.thresholdbytes onto thelarge_payload.diskStorage disk, keeping only a pointer and the body's sha256 in the row;$call->body()rehydrates the bytes. Offloaded objects are content-addressed and are not removed by retention pruning, which deletes rows only — reclaim them with a lifecycle policy on the disk or withphp artisan webhooks:prune-orphaned-payloads. -
Header redaction.
store_headersselects which headers persist; theredactlist (plusAuthorization,Cookie,Proxy-Authorizationand the threePHP_AUTH_*names, always) is masked before storage. The last three are not headers a producer sends: Symfony decodes anAuthorization: Basicline and puts the two halves back asPHP_AUTH_USERandPHP_AUTH_PW, so masking onlyAuthorizationleft the same password in clear text one key further down. -
The three swappable seams.
profiledecides which calls are processed and stored at all, so it is where a producer's noise is filtered out before it reaches your database — it defaults toProcessEverythingWebhookProfile, which is exactly what the name says.responsedecides what the producer gets back on success (status, body, headers), defaulting toDefaultRespondsTo.modelis the Eloquent model the call is stored as: point it at your own to add columns, or atSearchableWebhookCallto add indexing. Each takes a class name. -
The idempotency driver.
dedupepicks how the two-tier dedupe above is run.'redis+db'(the default) puts the cache fast path in front of the partial-unique store, so one cache read absorbs a retry storm before it reaches the database.'db'skips the cache and relies on the index alone — slower under a burst, but it needs no cache store and a cache eviction cannot defeat it.
Built-in receive adapters
Selected per source via scheme:
scheme | Verifies | Replay window | Sends webhook-id |
|---|---|---|---|
StandardWebhooksScheme (default) | Any Standard Webhooks producer — including this package's own Server | yes | yes |
'auto' | States first-party intent explicitly; resolves to Standard Webhooks | yes | yes |
StripeStyleScheme | The generic Webhook-Signature: t=,v1= dialect — the 0.x format this package used to send, so a 0.x consumer of yours keeps verifying | yes | no |
StripeScheme | Stripe's Stripe-Signature: t=,v1= header | yes | no |
GitHubScheme | GitHub's X-Hub-Signature-256: sha256= header | no | no |
PlainHmacScheme | A raw-body HMAC in a Signature header | no | no |
Ed25519Scheme | The asymmetric v1a variant (static key or a JWKS endpoint) | yes | yes |
Both columns are properties of the wire format, not of this package, and they are different
questions. A dialect that signs no timestamp gives tolerance_seconds nothing to check, so a
verification through it never returns expired and no adapter could add the window. A dialect
that sends no webhook-id leaves an unset dedupe_id with nothing to read, so the key stays
null — and a null collides with nothing, in the partial unique index and in the cache fast path
alike.
Four of the seven rows send no webhook-id, including two that do have a replay window.
Stripe signs a timestamp and still carries its delivery id in the body (evt_…), so a source
configured without dedupe_id is bounded in time and not idempotent: inside the tolerance
window the same authentic delivery is processed as often as it arrives, and Stripe's own retry
schedule lives inside it. php artisan webhooks:preflight names any source in that state, and
says which of the two boundaries is missing.
StripeStyleScheme and StripeScheme are not interchangeable: the first reads the
generic Webhook-Signature header, the second pins Stripe's own Stripe-Signature. Pick by
the header the producer actually sends.
Because the default receive scheme is Standard Webhooks, an app verifies its own deliveries
with scheme => 'auto' and no extra plumbing — a first-party round trip.
Before verification, nothing here throttles
This package registers no middleware of its own on the receiving route, and rate_limit runs
after the signature is checked. So the work between an anonymous POST arriving and it being
refused — reading the body, resolving the key material, verifying — happens once per request, at
whatever rate requests arrive.
For a shared-secret source that work is a hash and costs nothing worth defending. For a jwks
source it is an outbound HTTP call to the provider. A key set is cached for its TTL, but an
unsuccessful fetch deliberately is not — otherwise one bad minute at the provider would reject
every delivery for the rest of the hour — so while the provider is unwell, each anonymous request
tries again. That is an amplifier pointed at somebody else's endpoint, in your name.
An unresolvable key set is answered as a refusal rather than a 500 (undetermined_status,
falling back to invalid_status), so this never asks the caller to retry. What the package cannot
do for you is decide the rate at which strangers may reach the route. Put a throttle in front of
it in your own stack:
Route::middleware('throttle:60,1')->group(function (): void {
Route::webhooks('incoming/provider', 'provider');
});
Keyed per sender by default, which is the axis rate_limit cannot use — its bucket is per source,
because after verification the source is the only thing known to be real.
And nothing here caps the body size either
The only thing bounding an incoming body is your PHP installation's post_max_size. This
package sets no limit of its own, and that is worth knowing rather than discovering: a 3 MiB
delivery is accepted, stored, and carried a second time in the job that goes onto the queue — so
on a Redis queue it is the Redis server's memory too. It takes your shared secret to send one, so
this is not an anonymous flood; the ordinary case is a producer that attaches a document by
mistake.
Two levers exist, and neither is on by default:
large_payload.enabledmoves a body overlarge_payload.thresholdbytes onto a disk and keeps only a pointer and a hash in the row. That fixes the storage half without refusing anything.- A body-size limit that refuses belongs in front of the route, next to the throttle above,
because a refusal is a policy about who may send you what — and answering
413is your decision, not this package's.
There is no max_body_bytes setting here on purpose. A default would either be too low for
somebody or too high to be worth having, and the two levers above already cover the storage cost
and the refusal separately.
Not every producer sends JSON
Mollie posts application/x-www-form-urlencoded with a single field, id=tr_…. Others send
XML, or a body with no content type at all. The receive side reads two formats and is explicit
about the third case:
| The body | $message->format | $message->payload |
|---|---|---|
| valid JSON that decodes to an array | Json | the decoded body |
a form the producer declared as application/x-www-form-urlencoded | Form | its fields |
| absent, or whitespace only | None | empty — nothing was sent |
anything else, including multipart/form-data | Unreadable | empty — and something was sent |
JSON is attempted first, whatever the request declared. A content type is not evidence: a
request built without one is stamped application/x-www-form-urlencoded by the HTTP layer, and
producers really do send JSON under a wrong type or none. So the declared type is only ever
permission to try the form decoder, never a reason to skip JSON — a wrong header cannot cost a
JSON delivery its payload. application/vnd.provider+json needs no special handling for the
same reason.
The reverse does not hold, deliberately: a form body with no declared content type stays
Unreadable rather than being guessed at. parse_str cannot fail — it reads not json at all
as one empty-valued key and reports success — so guessing would invent payloads.
The payload is safe to store, the raw body is exact
$message->payload has had NUL bytes removed from its keys and values. That is not cosmetic:
PostgreSQL's jsonb type categorically cannot hold one, so a payload carrying a NUL — a
truncated string from a C library, a mangled cell, a binary blob a caller believed was text —
would make your own insert fail. Because the removal happens where the body is read, the
envelope your handler receives and the row the package stores are the same view, and you can
write $message->payload straight into a jsonb column of your own.
The removal is lossy, so the original is kept beside it: $call->body() returns the exact bytes
that were received and signature-verified, and hash('sha256', $call->body()) always equals
$call->body_sha256. Reach for body() whenever you need the delivery verbatim — re-encoding
the payload would not give it back, because encoding changes whitespace, escaping and float
formatting.
Numbers are the other place the two views differ, and it is worth knowing before you build on
the parsed one. JSON has no notion of integer width, and PHP's decoder does: an integer past
what an int holds arrives as a float, so 12345678901234567890 reads back as
1.2345678901234567e+19. That is json_decode and not something this package chose. A value past
what a double holds — 1e400 — decodes to infinity, which JSON cannot write back, so the stored
view carries INF, -INF or NAN under those names rather than failing the delivery.
Both are lossy in the same direction and neither touches body() or body_sha256. If a producer
sends an identifier as a number — a payment id, an account number — read it from body(), or ask
them to send it as a string, which is what every large-identifier API does for exactly this reason.
multipart/form-data is the one type read as a refusal rather than as permission, and it is
refused only after JSON has had the body — so a JSON envelope mislabeled multipart/form-data is
still read, exactly as the rule above promises. PHP consumes a multipart POST into $_POST
and $_FILES before any middleware can capture it, so the raw body usually arrives empty; on a
put or patch route the bytes survive, and the package still does not parse them. Either way
Unreadable is the true answer, where None would have claimed the producer sent nothing about
a delivery that carried fields.
Why the last row is its own state
Unreadable and None both leave payload empty, and only one of them has lost anything.
Without the distinction the loss is invisible from every side: the handler finds no fields, has
nothing to do, marks the call processed and answers 200 — and the producer, told the delivery
succeeded, never sends it again. Nothing throws and nothing is logged.
So check it before you act on an empty payload:
use Pushery\Webhooks\Client\Jobs\ProcessWebhookJob;
final class HandlePartnerWebhook extends ProcessWebhookJob
{
public function handle(): void
{
if (! $this->message->format->readable()) {
// $this->webhookCall->body() returns the exact bytes, unread — including when a
// large payload was offloaded to a disk. Fail loudly, or decode them yourself,
// but do not report success.
throw new \RuntimeException("Unread {$this->webhookCall->source} delivery");
}
// ...
}
}
A Pushery\Webhooks\Client\Events\UnreadableWebhookPayload also fires, carrying the stored call, its
source config and the declared content type, so an app can alert on this without changing every
handler. It fires only once the row and the handler job are durable, and it carries the call
rather than the request so that a queued listener works — an event holding a live request
cannot be serialized onto a queue, and one that threw on the way there would destroy the delivery
it announces. The call is still stored and still answered with the configured success response:
the delivery is authentic, and asking the producer to retry bytes that will fail the same way
buys nothing.
A form body that PHP only partly read is treated as unread rather than as a partial read, because
a half-read payload is worse than one reported unread: a handler acts on it. Two cases reach that
answer — a body carrying more fields than max_input_vars (parse_str keeps the first N and
raises a warning, which is the only report there is), and one whose nesting defeats parse_str
entirely, leaving no fields at all.
One case does not, and it is worth knowing: a body that mixes ordinary fields with a single field
nested past PHP's max_input_nesting_level loses only that field, silently and with no warning of
any kind. It arrives as Form, carrying the fields that survived.
Form fields also carry whatever bytes the producer percent-escaped, which need not be valid UTF-8. Those are substituted as they are read, the same lossy-but-valid trade the stored payload already makes for NUL bytes — the exact bytes stay on the row.
One limit is worth knowing before you rely on a form producer's dedupe_id. No signature
scheme covers the Content-Type header — every one of them signs the body, and some a
timestamp and an id, but never the media type. So a replay that strips the header off an
otherwise authentic form delivery still verifies, arrives Unreadable, and yields no dedupe key.
It is caught rather than silent — that is what UnreadableWebhookPayload fires for — and a
scheme with a replay window (tolerance_seconds) bounds it in time, but a body-only HMAC has no
window to bound it with.
Verification that is not a signature
Some providers can't be verified by a pure function of the bytes: Mollie signs nothing (authenticity is an authenticated API call back to it), PayPal verifies through a cert-chain API keyed on a webhook ID, not a secret.
Point a config's verifier at a Pushery\Webhooks\Client\Verification\InboundVerifier — a
container-resolved class that receives the Request and WebhookConfig and returns a
VerificationResult. It takes precedence over scheme, makes secret optional, and leaves
the entire rest of the pipeline (rate limit, dedupe, store, dispatch, the
401-and-store-nothing path) untouched — so a non-HMAC provider gets everything the package
does without you rebuilding a controller.
Treat a failed provider callback as not valid, so an unreachable provider never turns the endpoint into an open write surface.
"I could not tell" is not "not authentic"
A signature scheme is a pure function of body, headers and secret, so it always reaches a
verdict. A verifier does I/O, and I/O has a third exit — and collapsing it into invalid
merges two opposite events:
| The provider… | What it means | What it is |
|---|---|---|
answers 404 | the payment never existed | a forgery — a security signal |
| does not answer (timeout, DNS, 5xx) | nothing was learned about this delivery | an outage over a probably-genuine delivery |
Return VerificationResult::undetermined() for the second. It is refused exactly as hard —
nothing is stored, nothing is dispatched, isValid() is false — but it arrives at
InvalidWebhookSignature with reason 'undetermined', so an alert on someone probing the
endpoint no longer fires on every provider outage.
It also reaches the sender, if you ask for it. undetermined_status answers that one
outcome separately:
// config/webhooks.php — one config entry
'undetermined_status' => 503,
503 tells the producer to try again; 401 tells it to give up. For a delivery that was in
all likelihood genuine, "give up" is the wrong instruction — and it is the only one the
receiver could give before. The option is unset by default and then falls back to
invalid_status, so an installation that configures nothing answers every refusal exactly as
it did before: a distinguishable answer is information a prober can read too, which makes it
the host's call rather than the package's.
Note what the distinction is not: it says whether the check completed, never which part of it failed. The caller learns no more about your verification than it did before.
Verifying over the bytes: use RawBody, never $request->json()
A signature scheme is handed the body already. A verifier is not — it gets the Request — and
at least one of the two cases above needs the exact bytes: PayPal's verify call checks the
document it sent, and its envelope carries the event as JSON under webhook_event.
use Pushery\Webhooks\Client\Http\RawBody;
public function verify(Request $request, WebhookConfig $config): VerificationResult
{
$body = RawBody::of($request); // the delivery's exact bytes
// ...
}
$request->json()->all() re-encoded with json_encode is not the delivery. / comes back
as \/, non-ASCII as \uXXXX, and key order is not guaranteed to survive. The provider then
answers about a different document.
The failure mode is the expensive one: the call succeeds, the provider returns HTTP 200 with a negative verdict, and production looks like "the provider rejects our webhooks" with no cause anywhere. Nothing throws, nothing logs, and no test goes red while the provider is faked.
RawBody::of() returns the bytes the raw-body capture stashed
before anything downstream could parse or re-encode them, falling back to the request's own
content when that middleware did not run. It is the same resolution the package's own pipeline
uses, so a verifier and the pipeline can never disagree about what "the body" was for one
delivery.
A different header name
A producer that uses a different header name needs no scheme class of its own — see
Signatures and interop for signature_headers and the
per-scheme defaults.