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, deleted or given another URL 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.

OutboxEntryState — an entry of the transactional outbox​

Stored in webhook_outbox.state. See A transactional outbox.

CaseStoredMeans
PendingpendingRecorded and not yet taken: due at its next attempt, or waiting for its turn in a stream
DelivereddeliveredThe receiver answered with a 2xx
RefusedrefusedThe receiver refused it for good, or the delivery gate refused to send it
GivenUpgiven_upserver.outbox.max_attempts failed attempts are used up
SkippedskippedTaken out of its stream unsent with Outbox::skip()

Only Delivered and Skipped let the later entries of a stream go out. A refused entry and one given up hold their stream until it is resent or skipped.

WebhookCallStatus — the inbound call log​

Stored in webhook_calls.status.

CaseStoredMeans
ReceivedreceivedVerified and stored; the handler job has not finished
ProcessedprocessedYour handler returned
FailedfailedThe queue gave up on your handler after its last attempt

The package moves a call to processed or failed itself. A status your handler writes is kept as written.

A request whose signature does not verify is answered 401 and never stored, so it has no status — see Receiving.

OwnerKeyType — the key type of the owner columns​

webhooks.platform.owner_key_type picks it before the first migration runs, and the three tables that name an owner take the column it describes.

CaseConfigured asMeans
BigintbigintAn unsigned big integer, the default
UuiduuidA UUID
UlidulidA ULID

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.

SignatureEncoding — how a plain HMAC signature is written​

Chosen per source with signature_encoding in its Client config, and read by the plain HMAC scheme.

CaseConfiguredMeans
HexhexLowercase hex, the default
Base64base64Standard Base64 with padding, the way Shopify and WooCommerce send it

A signature in the other encoding never matches, and that refusal looks exactly like a wrong secret.

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\Core\Http\Exceptions\ResponseTooLargeA GuardedFetch received, or was told it would receive, a body longer than the maxBytes it was given. The transfer ended at the limit and nothing of the body is handed back; limit carries the number. See Security
Pushery\Webhooks\Exceptions\InvalidFeedCursorThe after handed to Webhooks::feed() is not a cursor the feed wrote. An InvalidArgumentException: answer it like an invalid parameter, a 422 for example, and let the receiver read from the start
Pushery\Webhooks\Exceptions\FeedCursorExpiredThe after handed to Webhooks::feed() points before the delivery log's retention window (platform.retention_months). The deliveries after it may be gone, so the receiver resynchronizes and reads the feed from the start; a 410 is the usual answer. It carries retentionMonths
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\Server\Exceptions\UnknownSecretsResolverThe class a delivery resolves its secrets with does not implement ResolvesDeliverySecrets. Refused when the call is built, and at the attempt for a delivery queued with such a name. Also a configuration error
Pushery\Webhooks\Server\Exceptions\OutboxUnavailableOutbox::record() was called where the outbox cannot keep its promise: server.outbox.enabled is off, or no transaction is open on the connection the package stores its tables on. Thrown before anything is written
Pushery\Webhooks\Server\Exceptions\OrderedDeliveryUnavailableAn ordered delivery (PendingWebhook::orderedBy()) was dispatched where it cannot keep its order: server.ordering.enabled is off, it went through dispatchSync(), or its queue connection cannot hold a job back, which is the case for sync and for an SQS FIFO queue. Thrown at dispatch, before anything is queued. A configuration error as well
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, or it names a shared-secret scheme for public keys (a jwks url or a whpk_ secret), under which anyone could sign a delivery. 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, InboundWebhookVerified or InvalidWebhookSignature — threw. The outcome the event announces was already settled, 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.