Skip to main content

Coming from another webhook package

This package is a superset of the usual pair — one package that sends, one that receives — on one engine or the other, including MySQL, which is where most of those apps run. A send-only package needs no database at all, and a receive-only one ships a flat, engine-agnostic webhook_calls table. You do not have to switch databases to adopt this.

Replacing the sender

Replace the outbound builder with Pushery\Webhooks\Server\PendingWebhook (or the WebhookSender facade). Signing is Standard Webhooks by default; if your consumers verify an older Signature-header HMAC, keep sending that by setting the scheme per call.

Backoff, Retry-After, per-call timeouts, SSRF pinning and mutual TLS are all built in — see Sending. No table to migrate: sending is stateless.

Replacing the receiver

Replace the route and its processing job with Route::webhooks('your/path', 'source-name') and a config profile — see Receiving.

Ours verifies, de-duplicates on receipt (two-tier), redacts headers, offloads over-sized bodies and keeps the exact received bytes, so hash('sha256', $call->body()) === $call->body_sha256.

Our webhook_calls is a superset of the usual shape, and one difference is worth naming because it decides cost rather than behavior: created_at carries its own index (webhook_calls_created_idx), so retention pruning stays cheap as the log grows.

Backfilling the old rows

New receipts flow into this package's webhook_calls from the moment you switch the route over — nothing else is required to go live. Carrying the old rows across is a one-time copy, and php artisan webhooks:import-calls does it.

It reads the shape you declare

The importer does not assume a schema. Five options name the columns to read, and their defaults describe the shape these tables almost always have:

OptionDefaultWhat it reads
--from-idideach row's primary key — what the derived, idempotent target id is built from
--from-sourcenamewhich producer the row came from. Empty imports as default
--from-payloadpayloadthe decoded JSON body
--from-headersheadersthe request headers as JSON. Missing or empty imports as no headers
--from-errorexceptionthe failure record. Its presence marks the imported row failed

A table that spells them differently needs no code, only options:

php artisan webhooks:import-calls \
--from-table=inbound_hooks --from-source=provider --from-error=last_error --dry-run

Point it at the source with --from-table and --from-connection — a source table is often also named webhook_calls, so a same-database import needs one of these to name it distinctly. Preview the counts with --dry-run.

Run it as often as you like

Each imported row's primary key is derived deterministically from (source, source row id), so a second run re-derives the same ids and imports nothing new.

warning
Give each import its own --source

The key is (source, row id). Import two tables without distinguishing them and rows whose sources agree and whose ids overlap collapse onto each other — the second import reports the collisions as "already present" and those rows are simply not there. That is what --source is for; pass it once per import and the two can never meet.

Two caveats, both forced by what such a table stores

It keeps only the parsed payload, never the raw received bytes, so an imported row cannot carry the producer's original body_sha256. The command reconstructs a self-consistent one from the re-encoded payload — verifiable against itself, but a reconstruction rather than the wire bytes.

Every row is written in a terminal state (processed, or failed where the source recorded an error), and no handler is dispatched over months-old history. The error column is read for that decision and its text is not carried: this log has no column for it, and copying an unbounded, unredactable stack trace out of somebody else's table into this one is not something the import should do quietly. Keep the source table until you are sure you no longer need those traces.

Treat imported rows as historical records, not re-verifiable ones.

Upgrading from 2.x with a backlog you already imported

The derived key changed in 3.0, so ids from an earlier import no longer re-derive. Running the new command over the same source table would import all of it a second time — the old rows are indistinguishable from ones this package received itself, so nothing can detect it for you.

--dry-run shows it before any write: over a backlog you already imported it must report everything as already present. If it reports rows to import, this is what you are looking at, and the answer is not to run it.