Skip to main content

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.

CaseStoredMeans
PendingpendingQueued, not yet attempted or awaiting a retry
SucceededsucceededThe endpoint answered with a success status
FailedfailedAn attempt failed and another is still due
ExhaustedexhaustedEvery attempt is spent; nothing more will be tried
RefusedrefusedNever 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.

CaseStoredMeans
ReceivedreceivedVerified and stored; the handler job has not finished
ProcessedprocessedYour handler completed
FailedfailedYour 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.

CaseStoredMeans
JsonjsonValid JSON that decoded to an array
FormformA body the producer declared as application/x-www-form-urlencoded
NonenoneNo body was sent, so nothing is missing
UnreadableunreadableBytes 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.

CaseStoredMeans
ValidvalidSignature matches and is inside the tolerance window
InvalidinvalidSignature does not match
ExpiredexpiredSignature matches but the timestamp is outside the window
MalformedmalformedThe header could not be parsed at all
UndeterminedundeterminedThe 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.

CaseStoredMeans
HealthyhealthyDelivering normally
DegradeddegradedFailing enough to be worth watching
FailingfailingFailing badly enough to act on
UnknownunknownNot 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

ExceptionThrown when
Pushery\Webhooks\Exceptions\InvalidPayloadExceptionAn 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\UnusableSigningSecretA 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\BlockedDestinationThe SSRF guard refused the destination — a private, loopback or link-local address, or a host outside your allowlist. See Security
Pushery\Webhooks\Exceptions\SubscriptionNotListeningA 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\TestPingThrottledA 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\MissingSigningKeyAsymmetric signing is switched on but no key material is configured. A configuration error, not a runtime condition
Pushery\Webhooks\Server\Exceptions\UnknownSignatureSchemeA configured scheme class does not resolve to a real scheme. Also a configuration error
Pushery\Webhooks\Client\Exceptions\CorruptRawBodyA stored inbound call cannot return the bytes it received. Relevant if you replay from webhook_calls
Pushery\Webhooks\Client\Exceptions\WebhookConfigCannotVerifyA 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.

ExceptionThrown when
Pushery\Webhooks\Core\Http\Exceptions\NonRetryableA failure that must not be retried. The delivery job marks the delivery final instead of scheduling another attempt
Pushery\Webhooks\Server\Exceptions\DeliveryRefusedA queued delivery was refused before it left — its endpoint was switched off or deleted between queueing and sending
Pushery\Webhooks\Server\Exceptions\QueueCannotRetryThe 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\HostUnresolvableThe 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\MalformedResponseFramingThe 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\OffloadFailedA large body could not be written to, or read back from, the offload disk. See Reliability
Pushery\Webhooks\Core\Signing\Exceptions\InvalidMessageA WebhookMessage was constructed with inconsistent parts — a programming error in a custom scheme
Pushery\Webhooks\Client\Exceptions\InboundListenerFailedA 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.