Status values and exceptions
These are the values you read out of the tables and the exceptions you may catch. All of them are public API: the string values are what is stored, so a migration or a report can rely on them.
Status values
DeliveryStatus — the outbound delivery log
Stored in webhook_deliveries.status.
| Case | Stored | Means |
|---|---|---|
Pending | pending | Queued, not yet attempted or awaiting a retry |
Succeeded | succeeded | The endpoint answered with a success status |
Failed | failed | An attempt failed and another is still due |
Exhausted | exhausted | Every attempt is spent; nothing more will be tried |
Refused | refused | Never sent: the endpoint was disabled or deleted while this delivery sat in the queue |
Failed and Exhausted are the distinction that matters when you alert: Failed is in
flight, Exhausted is final and needs a human or a redelivery.
Refused is final too, and it is deliberately not Exhausted, because the endpoint never
answered — nothing about its health can be read from the row. The circuit breaker has always
made that distinction (a refusal is not charged to the failure streak), and the endpoint health
score now makes it as well: refused deliveries are left out of the score entirely. They are
still counted as undelivered in the operator rollup, where the question is what happened to
your deliveries rather than how an endpoint is behaving.
WebhookCallStatus — the inbound call log
Stored in webhook_calls.status.
| Case | Stored | Means |
|---|---|---|
Received | received | Verified and stored; the handler job has not finished |
Processed | processed | Your handler completed |
Failed | failed | Your handler threw |
A request whose signature does not verify is answered 401 and never stored, so it has no
status — see Receiving.
PayloadFormat — how an inbound body was read
Carried on the InboundMessage your handler receives as $this->message->format, not stored.
| Case | Stored | Means |
|---|---|---|
Json | json | Valid JSON that decoded to an array |
Form | form | A body the producer declared as application/x-www-form-urlencoded |
None | none | No body was sent, so nothing is missing |
Unreadable | unreadable | Bytes arrived and nothing read them — the payload is empty because it is unread |
None and Unreadable both leave the payload empty, and that is the whole point of separating
them: a handler that treats the second as the first reports success for a delivery it never
read, and the producer never sends it again. $message->format->readable() is false for
Unreadable alone — see Receiving.
VerificationStatus — the result of checking a signature
Returned inside a VerificationResult, not stored.
| Case | Stored | Means |
|---|---|---|
Valid | valid | Signature matches and is inside the tolerance window |
Invalid | invalid | Signature does not match |
Expired | expired | Signature matches but the timestamp is outside the window |
Malformed | malformed | The header could not be parsed at all |
Undetermined | undetermined | The check did not complete — only an InboundVerifier can produce it |
Expired and Invalid are deliberately distinct: a clock-skew problem and a wrong-secret
problem look identical if you collapse them, and they have different fixes.
Undetermined is the same argument one level out. The first four are complete for a
signature scheme — a pure function of the bytes always decides — but a verifier calls the
provider back, and a call that times out has learned nothing. Reported as Invalid it would
be indistinguishable from a provider answering "this payment does not exist", which is a
forgery. Every case here is still a refusal: only Valid lets a request through.
HealthStatus — endpoint health scoring
Stored in webhook_subscriptions.health_status while
health scoring is on.
| Case | Stored | Means |
|---|---|---|
Healthy | healthy | Delivering normally |
Degraded | degraded | Failing enough to be worth watching |
Failing | failing | Failing badly enough to act on |
Unknown | unknown | Not enough recent deliveries to judge |
Unknown is not a mild Healthy — it means the score has no basis yet, so do not page on it.
OwnerKeyType — the shape of your tenant key
bigint, uuid or ulid. It is a configuration choice made before the first migration,
because it decides the column type of every owner_id — see
Choosing your database.
Exceptions
Worth catching
| Exception | Thrown when |
|---|---|
Pushery\Webhooks\Exceptions\InvalidPayloadException | An event payload fails the JSON Schema declared for its type. Thrown at dispatch, so you can reject the bad payload where it originates rather than discovering it in the delivery log |
Pushery\Webhooks\Core\Signing\Exceptions\UnusableSigningSecret | A configured secret base64-decodes to nothing, so it would sign with an empty HMAC key — a signature anyone who sees the request can reproduce. The bare whsec_ prefix is the usual cause, which is what WEBHOOKS_..._SECRET=whsec_${SECRET} expands to with the variable unset. Raised on the sending side only; on the receiving side such a secret is skipped and the request is refused as invalid, because an unverifiable request must never surface as a 500. webhooks:preflight reports it too |
Pushery\Webhooks\Core\Http\Exceptions\BlockedDestination | The SSRF guard refused the destination — a private, loopback or link-local address, or a host outside your allowlist. See Security |
Pushery\Webhooks\Exceptions\SubscriptionNotListening | A targeted delivery (Webhooks::dispatchTo()) named an endpoint the fan-out would not have reached — it is inactive or auto-disabled, or it never subscribed to that event type. Refused rather than delivered, because sending it anyway is the one thing a subscription list exists to prevent; and refused rather than skipped, because a silently dropped event is found by the customer. It carries reason, since a disabled endpoint and an unsubscribed one want different fixes |
Pushery\Webhooks\Exceptions\TestPingThrottled | A manual test ping (Webhooks::ping()) exceeded the endpoint's allowance in platform.test_ping.max_per_minute. Catch it on any screen that offers a test button: it carries secondsUntilAvailable, so you can tell the reader when to try again instead of showing them a failure |
Pushery\Webhooks\Server\Exceptions\MissingSigningKey | Asymmetric signing is switched on but no key material is configured. A configuration error, not a runtime condition |
Pushery\Webhooks\Server\Exceptions\UnknownSignatureScheme | A configured scheme class does not resolve to a real scheme. Also a configuration error |
Pushery\Webhooks\Client\Exceptions\CorruptRawBody | A stored inbound call cannot return the bytes it received. Relevant if you replay from webhook_calls |
Pushery\Webhooks\Client\Exceptions\WebhookConfigCannotVerify | A client config carries no secret, no jwks and no verifier, so it cannot authenticate anything. On a Route::webhooks() endpoint you never see it: the request is refused with the config's invalid_status (401 by default), the same answer a rejected signature gets, and the fault is logged as a configuration error. You see it when you build a config yourself — it carries configName and the status to answer with. Find it before a producer does, with webhooks:preflight or WebhookConfig::configurationFaults() |
Thrown inside the delivery engine
You will see these in logs and failed-job payloads; catching them in application code is usually the wrong layer, because the engine already handles them.
| Exception | Thrown when |
|---|---|
Pushery\Webhooks\Core\Http\Exceptions\NonRetryable | A failure that must not be retried. The delivery job marks the delivery final instead of scheduling another attempt |
Pushery\Webhooks\Server\Exceptions\DeliveryRefused | A queued delivery was refused before it left — its endpoint was switched off or deleted between queueing and sending |
Pushery\Webhooks\Server\Exceptions\QueueCannotRetry | The delivery failed in a retryable way and the queue connection it ran on cannot retry. Only the sync connection can produce it: it has no worker, so a released job is never picked up again. Without this the delivery would have reached no terminal state at all — the row would sit unfinished for ever. It carries the original transport failure as its previous exception. webhooks:preflight warns about a sync-resolved server layer |
Pushery\Webhooks\Core\Http\Exceptions\HostUnresolvable | The webhook host resolved to no IP address. Deliberately not NonRetryable: PHP's resolver answers the same false for a name that does not exist, for SERVFAIL, for a resolver timeout and for a few seconds of lost network, and only the first is permanent. The delivery therefore gets its normal backoff budget rather than being given up after one attempt — and a short DNS wobble no longer counts toward the circuit breaker's consecutive-failure threshold |
Pushery\Webhooks\Core\Http\Exceptions\MalformedResponseFraming | The endpoint answered with a response carrying both Content-Length and Transfer-Encoding, which RFC 9112 §6.1 forbids because the two disagree about where the body ends. It is NonRetryable: the next attempt reaches the same endpoint and gets the same bytes, so the delivery fails final instead of spending its whole budget on identical refusals. Guzzle 8 refuses such a response itself and guzzle 7 does not, so the package makes the refusal at its own boundary and both behave the same. The fix is at the endpoint — a response can name its length or chunk it, not both |
Pushery\Webhooks\Core\Payload\Exceptions\OffloadFailed | A large body could not be written to, or read back from, the offload disk. See Reliability |
Pushery\Webhooks\Core\Signing\Exceptions\InvalidMessage | A WebhookMessage was constructed with inconsistent parts — a programming error in a custom scheme |
Pushery\Webhooks\Client\Exceptions\InboundListenerFailed | A listener on a guarded inbound event — UnreadableWebhookPayload or InboundWebhookVerified — threw. The delivery was authentic and already safe, so the package reports this instead of failing the request. It wraps the listener's own exception, which Laravel's handler would otherwise skip for a whole documented set of classes; the message names which event it was |
The engine turns a failed attempt into a delivery row and a lifecycle event; listen to the events rather than catching inside the job.