Skip to main content

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.

A receiving route behind CSRF answers 419 to everything

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 (401 by default) — never 500, because a request that can never verify must not tell the sender to retry. Each failure fires a Pushery\Webhooks\Client\Events\InvalidWebhookSignature.

  • Replay protection. For a dialect that signs a timestamp, it is checked against tolerance_seconds (default 300). Two built-in adapters sign none — see the Replay window column in Built-in receive adapters — and for those tolerance_seconds is never consulted, so those two sources have no replay protection at all, and nothing you can set here gives them any. webhooks:preflight says so per source.

    The dedupe_id below 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-id header; providers that carry no delivery-id header (Stripe's evt_… is in the body, Mollie's tr_… is a form field, SendCloud sends none) set dedupe_id to read it from elsewhere — 'header:X-Delivery-Id', 'body:id' (a dotted path into the decoded body, whichever format it arrived in), or a Pushery\Webhooks\Client\Dedupe\DedupeKeyResolver class. 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 about

    Stripe's data.object.id is the invoice or the charge — every event about that object carries it. Keying dedupe on it makes invoice.payment_failed a duplicate of the invoice.paid that came before it, and the second delivery is acknowledged and dropped without a trace. Stripe's delivery id is the envelope's own id (evt_…), which is why the path above is body:id.

  • Event type. The column a stream is split on, and what process routes by. By default it is the body's own type field, which is what a Standard Webhooks producer sends. Producers that name their events elsewhere need event_type, with the same grammar as dedupe_id'header:X-GitHub-Event', 'body:data.kind', or a Pushery\Webhooks\Client\EventTypeResolver class. GitHub is the case to know: it sends the type in X-GitHub-Event and leaves the body carrying only action, 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 and action together (release.published), which neither half gives you alone.

  • Secret rotation. previous_secret keeps 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 refused 401 until 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, jwks or verifier. An entry with none of the three is refused with its invalid_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 does

    Nothing 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:preflight on deploy, or wire Pushery\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. process is either a single ProcessWebhookJob subclass or an ['event.type' => JobClass, '*' => FallbackJob] map. The job receives the stored call and the parsed envelope ($this->message).

  • Rate limiting. rate_limit is a per-source token bucket with two keys — rate_limit.max_attempts (how many requests) over rate_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 a jwks source verification is an outbound call to the provider; see Before verification, nothing here throttles. Omit the key, or leave it null, and the source is received without limit; that is the default. An over-limit request is answered 429 with Retry-After and 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.enabled moves a body over large_payload.threshold bytes onto the large_payload.disk Storage 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 with php artisan webhooks:prune-orphaned-payloads.

  • Header redaction. store_headers selects which headers persist; the redact list (plus Authorization, Cookie, Proxy-Authorization and the three PHP_AUTH_* names, always) is masked before storage. The last three are not headers a producer sends: Symfony decodes an Authorization: Basic line and puts the two halves back as PHP_AUTH_USER and PHP_AUTH_PW, so masking only Authorization left the same password in clear text one key further down.

  • The three swappable seams. profile decides 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 to ProcessEverythingWebhookProfile, which is exactly what the name says. response decides what the producer gets back on success (status, body, headers), defaulting to DefaultRespondsTo. model is the Eloquent model the call is stored as: point it at your own to add columns, or at SearchableWebhookCall to add indexing. Each takes a class name.

  • The idempotency driver. dedupe picks 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:

schemeVerifiesReplay windowSends webhook-id
StandardWebhooksScheme (default)Any Standard Webhooks producer — including this package's own Serveryesyes
'auto'States first-party intent explicitly; resolves to Standard Webhooksyesyes
StripeStyleSchemeThe generic Webhook-Signature: t=,v1= dialect — the 0.x format this package used to send, so a 0.x consumer of yours keeps verifyingyesno
StripeSchemeStripe's Stripe-Signature: t=,v1= headeryesno
GitHubSchemeGitHub's X-Hub-Signature-256: sha256= headernono
PlainHmacSchemeA raw-body HMAC in a Signature headernono
Ed25519SchemeThe asymmetric v1a variant (static key or a JWKS endpoint)yesyes

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.enabled moves a body over large_payload.threshold bytes 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 413 is 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 arrayJsonthe decoded body
a form the producer declared as application/x-www-form-urlencodedFormits fields
absent, or whitespace onlyNoneempty — nothing was sent
anything else, including multipart/form-dataUnreadableempty — 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 meansWhat it is
answers 404the payment never existeda forgery — a security signal
does not answer (timeout, DNS, 5xx)nothing was learned about this deliveryan 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
// ...
}
Never rebuild the body from the parsed request

$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.