Querying the tables yourself
If you write your own health check or report against the package's tables —
WebhookDelivery, WebhookCall, WebhookServerDelivery — bind timestamps through the
shipped scopes, never a plain ->where('created_at', …).
Every timestamp column is timestamptz (PostgreSQL) or a UTC-naive DATETIME(6) (MySQL); a
naive literal is resolved against the database session time zone, which is unrelated to
app.timezone and routinely not UTC — so a hand-bound comparison is off by that offset and
quietly returns the wrong rows, with no error to notice. A health check written that way can
never fire.
The scopes bind the instant, per dialect, so you can't get it wrong:
use Pushery\Webhooks\Models\WebhookDelivery;
// "Are deliveries piling up undelivered because no worker is running?"
$stuck = WebhookDelivery::query()
->pendingSince(now()->subMinutes(15))
->count();
WebhookDelivery::query()->createdBefore(now()->subDay()); // strictly before
WebhookDelivery::query()->createdAfter($since); // at or after
WebhookDelivery::query()->createdBetween($from, $to); // half-open [from, to)
WebhookDelivery::query()->whereTimestamp('delivered_at', '>=', $since); // any column
For a raw statement the scopes don't cover, (new WebhookDelivery)->boundTimestamp($moment)
returns the same offset-correct literal to bind by hand.
A raw format constant is deliberately not exposed: the correct literal differs by engine, so a copied constant would be right on one and wrong on the other — only a dialect-aware binding is safe.
Looking up a delivery by an id you were sent
An id that reaches your code from outside, such as a Livewire action argument or a query
parameter, need not be a UUID at all. PostgreSQL's uuid column answers other text with an
error (22P02) rather than with no row, so find($id) turns a tampered id into a server error
there and into a plain miss on MySQL. whereSubmittedKey() checks the id first and matches
nothing when it cannot be a delivery's, so both engines answer not found:
$delivery = WebhookDelivery::query()->whereSubmittedKey($id)->firstOrFail();
A {delivery} route parameter needs none of this: Laravel's route model binding makes the same
check before it queries.
Reading payload_type
payload_type on webhook_deliveries and webhook_calls is generated from the payload's own
type member, and the two engines agree on every value but one. For a payload whose type is
JSON null, PostgreSQL reads NULL and MySQL reads the four-character string null, the same
as for a type of "null". A payload without a type reads NULL on both.
So whereNull('payload_type') finds a payload without a type on both engines, and one whose
type is null on PostgreSQL alone. A row whose payload was offloaded to a disk reads exactly
what the same payload reads inline, on either engine.
Writing rows yourself
Reading is not the only direction that can go quietly wrong. If you insert into these tables
directly — a demo seeder, a fixture, a backfill of your own — write through the models
rather than a raw INSERT. Three columns of webhook_deliveries refuse a hand-written row,
and one of them refuses it on only one of the two engines:
| What the insert did | PostgreSQL 18 | MySQL 8.4 |
|---|---|---|
put a non-UUID in event_id (a ULID, say) | 22P02 — rejected | accepted, stored verbatim |
supplied a value for payload_type | 428C9 — rejected | 3105 — rejected |
omitted id | 23502 — rejected | 1364 — rejected |
The last two rows are loud on both engines, so they cost you a red run and nothing else.
payload_type is a STORED generated column mirroring the payload's own type field — it is
computed, never written. id has no database-side default anywhere: the model supplies a
UUIDv7 through Laravel's HasUuids, and a raw insert has to supply one too.
The first row is the one worth remembering, because it is silent on exactly one engine.
event_id is a real uuid on PostgreSQL and a char(36) on MySQL, so a 26-character ULID
overflows nothing and is stored as-is. That value is not private bookkeeping: it is sent as
the Standard Webhooks webhook-id header, and a redelivery re-signs with the same id
precisely so the receiver can recognize it. A row written with a non-UUID id therefore
carries that id out to the endpoint — where the two engines now disagree about what your
system even emits.
So the rule is the same one the read side arrives at from the other direction: let the
package mint the value. Dispatching through Webhooks::dispatch() generates the event id as
a UUIDv7 itself, and the model fills id and leaves payload_type alone. A row you write by
hand has to be exactly as strict about event_id — on the engine that would have told you,
and on the one that would not.