ArtisanPack UI
DocsBookingsOutbound Webhooks

Outbound Webhooks

Bookings v1.0.0

Subscribe an endpoint and the booking lifecycle fans out to it on its own — no wiring, no listener of your own: Seven events are raised, one per...

Subscribe an endpoint and the booking lifecycle fans out to it on its own — no wiring, no listener of your own:

use ArtisanPackUI\Bookings\Models\Webhook;

Webhook::create( [
    'name'   => 'Zapier',
    'url'    => 'https://hooks.zapier.test/bookings',
    'secret' => Str::random( 40 ),
    'events' => [ 'booking.confirmed', 'booking.cancelled' ],
] );

The events

Seven events are raised, one per lifecycle transition:

Event Raised when
booking.created A booking is made, whether or not it is yet confirmed
booking.confirmed It becomes an appointment
booking.rescheduled It moves; carries data.previous_period
booking.reassigned It moves to another provider; carries data.previous_provider_id
booking.cancelled It is called off; carries data.reason
booking.completed It is marked as having happened
booking.no_show The customer did not arrive

A booking that is auto-confirmed raises booking.created and booking.confirmed in that order.

The envelope

Each delivery is queued as its own job and recorded in booking_webhook_deliveries. The body is an envelope around the booking:

{
  "event": "booking.confirmed",
  "occurred_at": "2026-08-10T20:41:56+00:00",
  "data": {
    "booking": {
      "id": 4711,
      "booking_number": "BK-8F2A1C",
      "status": "confirmed",
      "start_time": "2026-08-14T13:00:00+00:00",
      "end_time": "2026-08-14T13:30:00+00:00",
      "customer": { "name": "Dana Scully", "email": "dana@example.test", "timezone": "Pacific/Auckland" },
      "service": { "id": 3, "name": "Consultation", "slug": "consultation" },
      "provider": { "id": 8, "name": "Alex Kim", "slug": "alex-kim" }
    },
    "actor": "customer"
  }
}

Times are UTC, with the customer's own zone named beside them rather than applied to them.

Signing & verification

Each request carries the event, the delivery id, the attempt number, a timestamp, and a signature:

X-ArtisanPack-Event: booking.confirmed
X-ArtisanPack-Delivery: 4711
X-ArtisanPack-Attempt: 1
X-ArtisanPack-Timestamp: 1767024000
X-ArtisanPack-Signature: sha256=<hex>

The signature is HMAC-SHA256( "{timestamp}.{body}", secret ) over the exact bytes of the request body. Verify it in constant time, and reject a timestamp older than your own tolerance — that is what stops a captured request from being replayed:

$signed = $request->header( 'X-ArtisanPack-Timestamp' ) . '.' . $request->getContent();

if ( ! hash_equals( 'sha256=' . hash_hmac( 'sha256', $signed, $secret ), $request->header( 'X-ArtisanPack-Signature' ) ) ) {
    abort( 401 );
}

Retries & disabling

A delivery is accepted on any 2xx. Anything else — a 500, a refused connection, a timeout — is a failure, and the delivery is retried on webhooks.delivery_backoff_minutes, which defaults to 1, 5, 30, 120, and 720 minutes. That is the list of delays between attempts, so an endpoint gets six attempts over about fourteen hours before the delivery is marked dead.

Failures are counted on the endpoint rather than on the delivery, so an endpoint returning 500 to everything is disabled after webhooks.failure_threshold consecutive failures — and a single success resets the count. Disabling fires the WebhookDisabled event, which is where an application tells the consumer their integration has stopped; nothing else will.

Deliveries are pushed onto the default queue unless webhooks.queue names one. Give them their own queue on an installation where a slow consumer must not delay everything else that is queued.

Raising your own events

Raise your own events through the same machinery when you have something to say that the lifecycle does not cover:

use ArtisanPackUI\Bookings\Services\WebhookDispatcher;

app( WebhookDispatcher::class )->dispatch( 'booking.confirmed', $payload, $siteId );

Pass the site when you know it. Left out, the endpoint list is scoped to whatever site is in context — which in console is none of them, and therefore all of them.

Security

The endpoint URL is guarded against SSRF. The address is checked against loopback, private, link-local, and unique-local ranges at delivery time, the vetted address is pinned into the connection to close the DNS-rebinding window, and redirects are refused. Apply the ValidWebhookUrl rule when you accept an endpoint URL below operator trust. This is covered in full under Webhook Security.

Every subscribed endpoint receives the full payload, including the customer's PII. The data.booking payload carries the customer's name, email, and their intake_data answers, delivered as-is to every endpoint subscribed to the event — the package applies no per-endpoint field filtering. Subscribe only endpoints you trust with that data, and treat the stored delivery ledger as holding personal data (it is retention-bound and swept by the erasure routine). A payload-filtering hook or a config flag to narrow what goes out is a candidate for a later release.

Retention

Settled deliveries are pruned by bookings:prune-webhook-deliveries on webhooks.delivery_retention_days (or retention.webhook_delivery_days when set). A pending delivery is never pruned. See GDPR, Retention & Erasure.

Stay in the Loop

Monthly tips, tutorials and package updates — plus a free Quick Start cheat sheet.

Subscribe
Edit this page on GitHub