Delivery log and resources
Every message in both directions is logged — status, attempts, response code, duration, error code and a safe message, the matched secret slot, a capped response excerpt and whether the answer was longer — never a secret, a signature or a credential header.
Models
| Model | Key | Table |
|---|---|---|
InboundEndpoint | models.inbound_endpoint | webhook_inbound_endpoints |
InboundDelivery | models.inbound_delivery | webhook_inbound_deliveries |
OutboundEndpoint | models.outbound_endpoint | webhook_outbound_endpoints |
OutboundDelivery | models.outbound_delivery | webhook_outbound_deliveries |
OutboundAttempt | models.outbound_attempt | webhook_outbound_attempts |
A replacement must extend the packaged model. Every instant is stored and read as UTC; every table has soft deletes (prune deletes for good). Relations: InboundEndpoint::owner() and deliveries(); InboundDelivery::endpoint(); OutboundEndpoint::owner() and deliveries(); OutboundDelivery::endpoint() and attemptLog(); OutboundAttempt::delivery(). The casts Casts\UtcDateTime, Casts\StoredPayload and Casts\StoredText are public, for reuse on a swapped model.
| Method | Returns |
|---|---|
InboundEndpoint::url() | The URL a sender posts to (inbound.route.url_base + / + key, else the named route). |
activeSecrets() | Both endpoint models: list<string> — the current secret, then the previous one until its grace ends. |
OutboundEndpoint::subscribesTo(string $eventType) | bool — the subscription match. |
OutboundDelivery::webhookId() | msg_<public_id> — the webhook-id receivers dedupe on, the same on every attempt and redelivery. |
Querying the log
Webhooks::inbound()->deliveries($endpoint)->where('status', 'failed')->latest('received_at')->paginate();
Webhooks::outbound()->deliveries()->where('status', 'retrying')->get();
Webhooks::outbound()->attempts($delivery); // every attempt of every series, in orderJSON resources
In RoundlyConsulting\Webhooks\Http\Resources. Instants render as ISO 8601 UTC, enums as their value — never a secret, ciphertext, header value or class name:
| Resource | Fields |
|---|---|
InboundEndpointResource | id, name, url, scheme, handler, enabled, disabled_reason, owner, last_received_at, secret_rotated_at, previous_secret_expires_at, created_at |
InboundDeliveryResource | id (public id), endpoint_id, event_type, status, signature_slot, received_at, processed_at, failed_at, runs, error_code, error_message, payload_pruned; ->withPayload(bool $include = true) adds the sanitized payload |
OutboundEndpointResource | id, name, description, url (scheme://host[:port]/…), host, event_types, header names, scheme, enabled, disabled_reason, consecutive_failures, last_success_at, last_failure_at, owner, created_at |
OutboundDeliveryResource | id (msg_…), endpoint_id, event_type, status, attempts, series, max_attempts, next_attempt_at, last_response_status, last_error_code, succeeded_at, failed_at, created_at |
OutboundAttemptResource | number, series, manual, started_at, duration_ms, response_status, response_truncated, error_code, error_message, retry_after_seconds |
use RoundlyConsulting\Webhooks\Http\Resources\InboundDeliveryResource;
use RoundlyConsulting\Webhooks\Http\Resources\InboundEndpointResource;
use RoundlyConsulting\Webhooks\Http\Resources\OutboundAttemptResource;
$endpoints = InboundEndpointResource::collection(Webhooks::for($team)->inbound()->endpoints()); // owner eager-loaded
$delivery = InboundDeliveryResource::make($inboundDelivery)->withPayload(); // + the sanitized payload
$attempts = OutboundAttemptResource::collection(Webhooks::outbound()->attempts($outboundDelivery));Return them from your own controllers. The package ships no management routes or UI — only the inbound receiving route — so the API and screens your customers use to manage their endpoints are yours, built on the sub-accessors and these resources. OutboundEndpointResource::redact(string $url) is the URL redaction on its own, for your own views.
The owner reference
An endpoint’s owner renders only through WebhookOwnerResource — {id, type} with the class basename, so no App\Models\Team goes over the wire. Point resources.owner at your own JsonResource to add a name or an avatar; it receives the owner model. Eager-load owner on endpoint collections (endpoints() does) — not loaded, owner renders null.
// config/webhooks.php
'resources' => ['owner' => App\Http\Resources\WebhookOwnerResource::class],Database schema
Five tables, named by webhooks.tables.*, on MySQL and PostgreSQL. Ids follow primary_key_type, the owner morph follows key_type; foreign keys restrict on delete, so a delivery keeps its endpoint row and an attempt its delivery until retention removes them in order:
| Table | Columns |
|---|---|
webhook_inbound_endpoints | owner morph (nullable), name, url_key (32, unique), signature_scheme, scheme_options, secret and previous_secret (encrypted, hidden), previous_secret_expires_at, secret_rotated_at, handler, the overrides (rate_limit_per_minute, max_payload_bytes, allowed_content_types, tolerance_seconds), enabled, disabled_at, disabled_reason, last_received_at. |
webhook_inbound_deliveries | public_id (ULID, unique), endpoint_id, idempotency_key (unique with endpoint_id — the dedupe backstop), external_id, event_type, status, headers, payload (stored, hidden), payload_bytes, payload_digest, payload_pruned_at, signature_slot, received_at, queued_at, processing_started_at, processed_at, failed_at, runs, attempts, duration_ms, last_error_code, last_error_message. |
webhook_outbound_endpoints | owner morph (nullable), name, description, url (encrypted, hidden), url_host, signature_scheme, scheme_options, secret and previous_secret (encrypted, hidden), event_types, headers (encrypted, hidden), rate_limit_per_minute, enabled, disabled_at, disabled_reason, consecutive_failures, last_success_at, last_failure_at. |
webhook_outbound_deliveries | public_id (the ULID behind msg_…), endpoint_id, event_type, body (stored, hidden), body_bytes, body_digest, payload_pruned_at, status, attempts, series, max_attempts, next_attempt_at, claimed_until, dispatch_token, dispatched_at, redispatch_at, last_attempt_at, succeeded_at, failed_at, last_response_status, last_duration_ms, last_error_code, last_error_message. |
webhook_outbound_attempts | delivery_id, series and number (unique with delivery_id), manual, started_at, duration_ms, response_status, response_body (capped excerpt), response_truncated, retry_after_seconds, remote_address, error_code, error_message, next_attempt_at. |
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.