NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Webhooks for Laravel

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

ModelKeyTable
InboundEndpointmodels.inbound_endpointwebhook_inbound_endpoints
InboundDeliverymodels.inbound_deliverywebhook_inbound_deliveries
OutboundEndpointmodels.outbound_endpointwebhook_outbound_endpoints
OutboundDeliverymodels.outbound_deliverywebhook_outbound_deliveries
OutboundAttemptmodels.outbound_attemptwebhook_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.

MethodReturns
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 order

JSON 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:

ResourceFields
InboundEndpointResourceid, name, url, scheme, handler, enabled, disabled_reason, owner, last_received_at, secret_rotated_at, previous_secret_expires_at, created_at
InboundDeliveryResourceid (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
OutboundEndpointResourceid, 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
OutboundDeliveryResourceid (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
OutboundAttemptResourcenumber, 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:

TableColumns
webhook_inbound_endpointsowner 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_deliveriespublic_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_endpointsowner 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_deliveriespublic_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_attemptsdelivery_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 crypto

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