Skip to main content

Sending (Server layer)

Pushery\Webhooks\Server\PendingWebhook is an immutable, fluent builder in the shape of Laravel's own PendingRequest/PendingMail: every setter returns a clone, so a half-built call is a reusable template. Pushery\Webhooks\Server\Facades\WebhookSender::to($url) is a thin, discoverable entry point to the same builder.

use Pushery\Webhooks\Server\PendingWebhook;

PendingWebhook::create()
->url('https://example.com/webhooks')
->payload(['invoice_id' => 'in_123']) // encoded to JSON and signed
->useSecret('whsec_…')
->forEventType('invoice.paid') // recorded and tagged
->dispatch();

Send a raw, pre-serialized body instead of an array with ->sendRawBody($body, 'application/json').

Secret rotation

Sign with the current and previous secret during a rotation window so a consumer that still holds the old secret keeps verifying while it migrates. For a registered endpoint (Webhooks::rotateSecret()) the window is bounded by platform.secret_rotation_window_hours (24 by default) and it CLOSES: once it has, the old secret is cleared from the row and can no longer sign or verify — which is the whole point of rotating away from it. php artisan webhooks:revoke-rotated-secrets (scheduled hourly) sweeps the endpoints that went quiet before their window elapsed.

PendingWebhook::create()
->url($url)
->payload($payload)
->useSecrets(current: 'whsec_new', previous: 'whsec_old')
->dispatch();

A different signing scheme

The default is the Standard Webhooks HMAC dialect; opt into another (for example asymmetric Ed25519) per call:

use Pushery\Webhooks\Core\Signing\Ed25519Scheme;

PendingWebhook::create()
->url($url)
->payload($payload)
->signUsing(Ed25519Scheme::class)
->useSecret('whsk_…') // base64 Ed25519 secret key
->dispatch();

Every shipped scheme and the exact wire format are on Signatures and interop.

Retries, backoff and Retry-After

The backoff is exponential with full jitter, capped. A retryable 429/503 carrying a Retry-After header is honored when scheduling the next attempt, clamped to its own ceiling (server.backoff.retry_after_cap — the longest wait the queue can hold a job for, which is a different quantity from the jitter cap). When an endpoint asks for longer than that, the delivery comes back at the cap and the wait is not charged against tries, so a long rate-limit window cannot exhaust the delivery before the endpoint is ready for it.

A cap of 0 switches the hint off rather than shortening it to nothing: the next attempt is drawn from the jittered schedule as if no header had arrived, and nothing is deferred. The other reading would answer Retry-After: 60 with an immediate retry against the endpoint that just asked for quiet — strictly worse than not honoring the header at all. respect_retry_after => false says the same thing more plainly.

use Pushery\Webhooks\Server\Backoff\ExponentialWithJitter;

PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->maximumTries(5)
->useBackoffStrategy(new ExponentialWithJitter(baseSeconds: 10, capSeconds: 900))
->respectRetryAfter() // on by default
->dispatch();

Timeouts, SSRF, mutual TLS, proxy

Every outbound URL is vetted by the shared SSRF guard, and a direct connection is pinned to the validated IP (see Security). Routing through useProxy() hands name resolution to the proxy, so the pin no longer applies — the proxy must enforce its own egress control.

PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->connectTimeoutInSeconds(3)
->timeoutInSeconds(5)
->verifySsl(true) // or a CA bundle path
->useMutualTls(cert: '/path/client.pem', key: '/path/client.key')
->useProxy('http://proxy.internal:8080')
->dispatch();

Metadata, tags, queue, connection

Attach arbitrary meta, add Horizon tags, and choose the queue/connection the delivery job runs on:

PendingWebhook::create()
->url($url)->payload($payload)->useSecret($secret)
->meta(['tenant_id' => $team->id])
->withTags(['billing', "team:{$team->id}"])
->onQueue('webhooks')
->onConnection('redis')
->dispatch();

Terminal methods: ->dispatch(), ->dispatchIf($cond), ->dispatchUnless($cond), ->toDeliveryData() (the immutable value object the job carries) — and ->dispatchSync(), which comes with a caveat worth knowing before you reach for it.

caution
dispatchSync() gets no retries

The sync connection has no worker, so a released job is never picked up again. A delivery that fails in a retryable way — a 5xx, a timeout, a reset — therefore ends after one attempt rather than using its configured tries, and the failure is reported as QueueCannotRetry so the row says why rather than looking like a dead endpoint.

The same applies to any host running QUEUE_CONNECTION=sync, whatever it dispatches with. webhooks:preflight warns when the server layer resolves to it.

Standalone delivery persistence (opt-in)

When you drive PendingWebhook directly without the Platform layer and still want a persisted, prunable record of every delivery, enable server.persistence.enabled: a listener upserts each attempt into a webhook_server_deliveries table keyed by the message id, and rows older than prune_after_days are removed by the scheduled model:prune.

Off by default — when the Platform layer runs it owns the delivery log instead, so the two never double-log.