laravel-sdk maintained by boothzen
BoothZen Laravel SDK
Official Laravel SDK for the BoothZen booking platform REST API. Built on Saloon v4, it gives your Laravel application typed resource clients, readonly DTOs, cursor-paginated collections, webhook signature verification, and a testing fake — all with zero hand-rolled HTTP code.
Supports PHP 8.1+ and Laravel 10 / 11 / 12.
Table of Contents
- Installation
- Configuration
- Quick Start
- Resources
- What's new in 0.7.x
- Webhook Verification
- Testing with BoothZen::fake()
- Exception Handling
- Sandbox Mode
- Versioning
- Links
- Contributing
- License
Installation
composer require boothzen/laravel-sdk
php artisan vendor:publish --tag=boothzen-config
The service provider is auto-discovered. No manual registration required.
Configuration
Add to your .env:
BOOTHZEN_API_KEY=bz_live_your_key_here
BOOTHZEN_WEBHOOK_SECRET=whsec_your_secret_here
BOOTHZEN_BASE_URL=https://app.boothzen.com/api/v1/ # optional override
BOOTHZEN_TIMEOUT=30 # optional, seconds
The published config/boothzen.php:
return [
'key' => env('BOOTHZEN_API_KEY'),
'webhook_secret' => env('BOOTHZEN_WEBHOOK_SECRET'),
'base_url' => env('BOOTHZEN_BASE_URL', 'https://app.boothzen.com/api/v1/'),
'timeout' => (int) env('BOOTHZEN_TIMEOUT', 30),
];
For secret rotation pass an array — both secrets are tried during the grace window:
BOOTHZEN_WEBHOOK_SECRET=whsec_new_secret
BOOTHZEN_WEBHOOK_SECRET_OLD=whsec_old_secret
'webhook_secret' => [env('BOOTHZEN_WEBHOOK_SECRET'), env('BOOTHZEN_WEBHOOK_SECRET_OLD')],
Quick Start
use BoothZen\Laravel\Facades\BoothZen;
// List recent bookings (returns PaginatedCollection<Booking>)
$bookings = BoothZen::bookings()->list(['limit' => 20]);
foreach ($bookings as $booking) {
echo $booking->id; // "bk_01HXYZ..."
echo $booking->total->amount; // 12500 (minor units, e.g. pence)
echo $booking->total->currency; // "GBP"
echo $booking->event_date->format(DATE_ATOM); // ISO 8601
echo $booking->status; // "confirmed"
}
// Cursor pagination
if ($bookings->hasMore) {
$next = BoothZen::bookings()->list(['cursor' => $bookings->nextCursor]);
}
Resources
Every resource is accessed via the BoothZen facade. All list methods return
PaginatedCollection<T> (implements Countable + IteratorAggregate).
All get/create/update methods return a typed DTO.
Bookings
use BoothZen\Laravel\Facades\BoothZen;
use BoothZen\Laravel\Data\Booking;
// List (paginated)
$page = BoothZen::bookings()->list(['limit' => 25, 'cursor' => null]);
// Get single
$booking = BoothZen::bookings()->get('bk_01HXYZ');
echo $booking->id; // "bk_01HXYZ"
echo $booking->status; // "confirmed" | "pending" | "cancelled"
echo $booking->is_test; // false (true for bz_test_ key requests)
echo $booking->total->amount; // v0.5.0+: pence/cents; computed from
// package_id + holiday rules at create time
$booking->metadata; // v0.5.0+: array<string,string> opaque bag
// Create (with package_id → total_price computed automatically from
// package.price + that date's holiday adjustment, v0.5.0+)
$booking = BoothZen::bookings()->create([
'customer_id' => 'cu_01HABC',
'package_id' => 'sv_01HDEF',
'event_date' => '2026-08-15T14:00:00+00:00',
'notes' => 'Birthday party — outdoor setup needed',
'metadata' => [ // v0.5.0+
'venue_id' => 'partner-venue-3142',
'lead_uuid' => '7b8d1ec2-4f60-4b1b-9f6a-...',
'ga4_client_id' => '1782913.4567',
],
]);
// Update
$booking = BoothZen::bookings()->update('bk_01HXYZ', [
'status' => 'confirmed',
'notes' => 'Updated setup requirements',
]);
Customers
use BoothZen\Laravel\Facades\BoothZen;
// List
$customers = BoothZen::customers()->list(['limit' => 50]);
// Get
$customer = BoothZen::customers()->get('cu_01HABC');
echo $customer->first_name; // "Jane"
echo $customer->last_name; // "Smith"
echo $customer->email; // "jane@example.com"
echo $customer->phone; // "+44 7700 900000" (nullable)
echo $customer->company; // "Acme Events Ltd" (nullable)
// Create
$customer = BoothZen::customers()->create([
'first_name' => 'Jane',
'last_name' => 'Smith',
'email' => 'jane@example.com',
'phone' => '+44 7700 900000',
]);
// Update
$customer = BoothZen::customers()->update('cu_01HABC', [
'phone' => '+44 7700 911111',
'company' => 'New Events Co',
]);
Leads
use BoothZen\Laravel\Facades\BoothZen;
// List
$leads = BoothZen::leads()->list(['limit' => 25]);
// Get
$lead = BoothZen::leads()->get('ld_01HGHI');
echo $lead->status; // "new" | "contacted" | "qualified" | "lost"
echo $lead->source; // "website" | "referral" | null
echo $lead->event_type; // "wedding" | "corporate" | null
// Create — when you already have a customer_id
$lead = BoothZen::leads()->create([
'customer_id' => 'cu_01HABC',
'event_date' => '2026-09-20',
'event_type' => 'wedding',
'source' => 'website',
'notes' => 'Interested in the 360 booth package',
'metadata' => [ // v0.5.0+: opaque k/v bag
'utm_source' => 'google',
'ga4_client' => '1782913.4567',
],
]);
// Anonymous capture (no existing customer) — v0.2.0+
// Two API round-trips wrapped in a single call. Returns both DTOs.
$result = BoothZen::leads()->createWithCustomer(
customer: [
'first_name' => 'Anon',
'last_name' => 'Visitor',
'email' => 'anon@example.com',
'phone' => '+44 7700 900000',
],
lead: [
'event_date' => '2026-09-20',
'event_type' => 'wedding',
'source' => 'website',
],
);
echo $result->customer->id; // "cu_01HABC"
echo $result->lead->id; // "ld_01HGHI"
echo $result->lead->customer_id; // "cu_01HABC"
Quotes
use BoothZen\Laravel\Facades\BoothZen;
// List
$quotes = BoothZen::quotes()->list(['limit' => 25]);
// Get
$quote = BoothZen::quotes()->get('qu_01HJKL');
echo $quote->status; // "draft" | "sent" | "accepted" | "declined"
echo $quote->total->amount; // 15000 (pence)
echo $quote->total->currency; // "GBP"
echo $quote->expires_at?->format('Y-m-d'); // "2026-06-01" (nullable)
echo $quote->accepted_at?->format(DATE_ATOM); // ISO 8601 (nullable)
Invoices
use BoothZen\Laravel\Facades\BoothZen;
// List
$invoices = BoothZen::invoices()->list(['limit' => 25]);
// Get
$invoice = BoothZen::invoices()->get('in_01HMNO');
echo $invoice->status; // "draft" | "sent" | "paid" | "void"
echo $invoice->amount->amount; // 15000 (pence)
echo $invoice->amount->currency; // "GBP"
echo $invoice->paid_at?->format(DATE_ATOM); // ISO 8601 when paid (nullable)
echo $invoice->booking_id; // "bk_01HXYZ" (nullable)
Services
use BoothZen\Laravel\Facades\BoothZen;
// List
$services = BoothZen::services()->list();
// Get
$service = BoothZen::services()->get('sv_01HDEF');
echo $service->name; // "360 Video Booth — 3 hrs"
echo $service->price->amount; // 25000 (pence)
echo $service->price->currency; // "GBP"
echo $service->duration_hours; // 3 (nullable)
echo $service->active; // true
Units
use BoothZen\Laravel\Facades\BoothZen;
// List
$units = BoothZen::units()->list();
// Get
$unit = BoothZen::units()->get('un_01HPQR');
echo $unit->name; // "Booth Alpha"
echo $unit->unit_type; // "360" | "mirror" | "photo" (nullable)
echo $unit->active; // true
Availability
use BoothZen\Laravel\Facades\BoothZen;
// List daily availability between two dates (v0.4.0+).
// Returns list<AvailabilityDay> — one row per calendar day in the range.
$days = BoothZen::availability()->list('2026-08-01', '2026-08-31');
foreach ($days as $day) {
echo $day->date->format('Y-m-d'); // "2026-08-15"
echo $day->available ? 'open' : 'full';
foreach ($day->slots as $slot) {
echo $slot->unit_id; // "un_01HPQR"
echo $slot->available; // true | false
}
// v0.4.0+: "from £X" — cheapest visible package on this date with
// holiday-rule adjustment applied. Null when no priceable package.
if ($day->price_from !== null) {
echo $day->price_from->amount; // 17900 (minor units)
echo $day->price_from->currency; // "GBP"
}
}
// Point query (legacy shape — see note below)
$slot = BoothZen::availability()->get([
'unit_id' => 'un_01HPQR',
'date' => '2026-08-15',
]);
Note: the legacy
get()method pre-dates the canonical list endpoint and doesn't round-trip cleanly against the platform. Uselist($from, $to)for new integrations.get()will be reworked in a future minor release.
Payment Intents
Added in v0.2.0. Create a Stripe payment intent against an existing
booking and capture the card inline using Stripe Elements — no redirect,
no iframe. The returned client_secret is what you hand to Stripe.js.
use BoothZen\Laravel\Facades\BoothZen;
use BoothZen\Laravel\Data\PaymentIntent;
// Charge the booking's full total
$intent = BoothZen::paymentIntents()->create([
'booking_id' => 'bk_01HXYZ',
]);
echo $intent->id; // "pi_3Otest_..." (Stripe identifier)
echo $intent->status; // "requires_payment_method"
echo $intent->client_secret; // pass to Stripe.js
echo $intent->amount->amount; // 25000 (minor units)
echo $intent->amount->currency; // "GBP"
// Charge a partial amount (deposit) — override amount_minor in pence/cents
$deposit = BoothZen::paymentIntents()->create([
'booking_id' => 'bk_01HXYZ',
'amount_minor' => 5000,
]);
// Retrieve a payment intent by its Stripe id — server-side reconciliation
// when the payment.received webhook hasn't arrived yet
$intent = BoothZen::paymentIntents()->get('pi_3test_abcdef');
echo $intent->status; // "succeeded" | "requires_payment_method" | ...
In your React/JS frontend, mount Stripe Elements with the client_secret:
const stripe = Stripe('pk_live_publishable_key');
const elements = stripe.elements({ clientSecret });
elements.create('payment').mount('#payment-element');
await stripe.confirmPayment({ elements, confirmParams: { return_url: '/thanks' }});
The authoritative post-payment signal is the payment.received webhook —
don't poll the payment intent endpoint. See Webhook Verification.
Scope required:
payments:write. Mint a key with this scope in the BoothZen dashboard → Settings → API Keys before calling.
Identity (Me)
Added in v0.2.0. Introspect the authenticated API key — returns the
tenant, the granted scopes, the mode (live / test), and when the key
was last used. Useful as a startup health check and for verifying the
scopes your integration needs are present before issuing real requests.
use BoothZen\Laravel\Facades\BoothZen;
$me = BoothZen::me()->get();
echo $me->tenant->id; // "tnt_42"
echo $me->tenant->slug; // "acme"
echo $me->tenant->name; // "Acme Events Ltd"
echo $me->mode; // "live" | "test"
$me->scopes; // ['bookings:read', 'payments:write', ...]
$me->key_last_used_at; // DateTimeImmutable | null
$me->stripe_publishable_key; // "pk_test_…" | "pk_live_…" | null (v0.6.1+)
// Defensive scope check before calling a guarded endpoint
if (! in_array('payments:write', $me->scopes, true)) {
throw new RuntimeException('API key missing payments:write scope');
}
// Mount Stripe Elements at app boot using the tenant's own publishable key.
// $me->stripe_publishable_key is mode-matched to your Bearer key
// (bz_test_ → pk_test_, bz_live_ → pk_live_). null when the tenant
// hasn't configured Stripe in their BoothZen tenant settings yet.
No scope is required to call /me — every authenticated key may
introspect its own identity.
Mounting Stripe Elements (BYO-Stripe partner integrations). For partners
building their own React/Vue checkout against a BYO-Stripe tenant, fetch the
publishable key at runtime from /v1/me and pass it to Stripe.js. The key is
mode-matched to your Bearer key, so it's guaranteed to pair with whichever
Stripe account the platform uses to create PaymentIntents — no more
build-time pk_… env var drifting out of sync with the server's sk_….
$me = BoothZen::me()->get();
if ($me->stripe_publishable_key === null) {
return response()->json(['error' => 'payments not yet configured'], 503);
}
return ['publishable_key' => $me->stripe_publishable_key];
Then on the React/JS side: const stripe = await loadStripe(publishableKey);
followed by stripe.elements({ clientSecret }) where clientSecret comes
from BoothZen::paymentIntents()->create(['booking_id' => $bk]). v0.6.1+.
Extras
Added in v0.6.0. Retrieve the catalogue of add-on extras that can be attached to bookings.
Fields: id, name, description (nullable), price (Money), upsell_price (Money|null), included_only (bool), icon_key (nullable), active (bool), sort_order (int|null), created_at, updated_at.
use BoothZen\Laravel\Facades\BoothZen;
// List (paginated)
$extras = BoothZen::extras()->list(['limit' => 50]);
// Get single
$extra = BoothZen::extras()->get('ex_01HABC');
echo $extra->name; // "Guestbook"
echo $extra->price->amount; // 4900 (pence)
echo $extra->upsell_price?->amount; // 3900 — portal upsell price (nullable)
echo $extra->included_only; // false — if true, not sold standalone
echo $extra->icon_key; // "book" (nullable)
echo $extra->active; // true
Unit Types
Added in v0.6.0. Retrieve unit-type definitions (e.g. "Open Booth", "360 Booth", "Mirror Booth").
Fields: id, name, customer_facing_name (nullable), description (nullable), icon_key (nullable), active (bool), is_visible (bool), requires_venue (bool), created_at, updated_at.
use BoothZen\Laravel\Facades\BoothZen;
// List
$types = BoothZen::unitTypes()->list();
// Get
$type = BoothZen::unitTypes()->get('ut_01HDEF');
echo $type->name; // "360 Booth"
echo $type->customer_facing_name; // "360 Video Experience" (nullable)
echo $type->requires_venue; // false
echo $type->is_visible; // true
Event Types
Added in v0.6.0. Retrieve the list of configured event types (e.g. "Wedding", "Corporate"). Note: this resource has no description field.
Fields: id, name, enabled (bool), sort_order (int|null), created_at, updated_at.
use BoothZen\Laravel\Facades\BoothZen;
// List
$eventTypes = BoothZen::eventTypes()->list();
// Get
$eventType = BoothZen::eventTypes()->get('et_01HGHI');
echo $eventType->name; // "Wedding"
echo $eventType->enabled; // true
echo $eventType->sort_order; // 1 (nullable)
Venues
Added in v0.6.0. Retrieve the operator's venue catalogue.
Fields: id, name, address_line1 (nullable), address_line2 (nullable), city (nullable), postcode (nullable), county (nullable), country (nullable), latitude (float|null), longitude (float|null), phone (nullable), email (nullable), created_at, updated_at.
use BoothZen\Laravel\Facades\BoothZen;
// List
$venues = BoothZen::venues()->list(['limit' => 25]);
// Get
$venue = BoothZen::venues()->get('vn_01HJKL');
echo $venue->name; // "The Grand Hotel"
echo $venue->city; // "London"
echo $venue->postcode; // "SW1A 1AA"
echo $venue->country; // "GB"
echo $venue->latitude; // 51.5074 (nullable float)
echo $venue->longitude; // -0.1278 (nullable float)
echo $venue->email; // "events@thegrand.example" (nullable)
Coupons
Added in v0.6.0. Retrieve discount coupon definitions. Note: this resource has no name field; the code is the human identifier. discount_value is returned as a string (e.g. "10.00") to preserve decimal precision.
Fields: id, code, discount_type ('percentage'|'fixed'), discount_value (string), valid_from (date string|null), valid_until (date string|null), active (bool), created_at, updated_at.
use BoothZen\Laravel\Facades\BoothZen;
// List
$coupons = BoothZen::coupons()->list();
// Get
$coupon = BoothZen::coupons()->get('cp_01HMNO');
echo $coupon->code; // "SUMMER10"
echo $coupon->discount_type; // "percentage"
echo $coupon->discount_value; // "10.00" (string — preserves decimal precision)
echo $coupon->valid_from; // "2026-06-01" (nullable date string)
echo $coupon->valid_until; // "2026-08-31" (nullable date string)
echo $coupon->active; // true
// Validate a code against a hypothetical order — v0.7.0+.
// Always returns 200; `valid:false` carries the reason inline.
$check = BoothZen::coupons()->validate([
'code' => 'EARLY100',
'subtotal_minor' => 18000,
'package_ids' => ['sv_11'], // any-of match against package_restrictions
'event_type' => 'Wedding', // optional
]);
echo $check->valid; // bool
echo $check->discount_amount->amount; // e.g. 1800 (minor units)
echo $check->reason; // e.g. "the coupon has expired" when !valid
Unit Blocks
Added in v0.6.0. Introspect operator blackout dates and reserved/booked days for one or more units. This resource is list-only (no get() method) and returns a plain array (not a PaginatedCollection) — the platform response has no cursor pagination. The server defaults to from = today and to = today + 30 days, capping the window at 90 days.
Query params: from (date string, optional), to (date string, optional), unit_id (string un_..., optional — filters to a single unit).
Row shape: unit_id (string), date (string), source ('operator'|'reserved'|'booked'), reserved_until (ISO8601 string|null).
use BoothZen\Laravel\Facades\BoothZen;
// All blocked days for all units, next 30 days (server default)
$blocks = BoothZen::unitBlocks()->list();
// Specific date range
$blocks = BoothZen::unitBlocks()->list([
'from' => '2026-06-01',
'to' => '2026-06-30',
'unit_id' => 'un_01HPQR', // optional — filter to one unit
]);
foreach ($blocks as $block) {
echo $block->unit_id; // "un_01HPQR"
echo $block->date; // "2026-06-15"
echo $block->source; // "operator" | "reserved" | "booked"
echo $block->reserved_until; // "2026-06-15T22:00:00Z" or null
}
What's new in 0.7.x
Lead and customer deletion (v0.7.1)
// Archive or update a lead
$lead = BoothZen::leads()->update('ld_01HGHI', ['status' => 'archived']);
// Allowed values: new | contacted | draft | quoted | engaged | won | lost | archived
// Hard-delete a lead (throws 409 'lead_has_bookings' if bookings exist)
BoothZen::leads()->delete('ld_01HGHI');
// Hard-delete a customer (throws 409 'customer_has_bookings' if any booking references them)
BoothZen::customers()->delete('cu_01HABC');
Customer archive flow (v0.7.2)
// Archive — hidden from default list but still resolved by get()
$customer = BoothZen::customers()->update('cu_01HABC', ['status' => 'archived']);
echo $customer->status; // "archived"
echo $customer->archived_at; // "2026-05-27T10:00:00Z" (ISO-8601)
// Unarchive
$customer = BoothZen::customers()->update('cu_01HABC', ['status' => 'active']);
Archived customers keep all their booking history intact — customers()->get($id) always resolves them so historical booking JSON keeps its customer context.
Booking customer portal URL (v0.7.2)
Every Booking now carries $customer_portal_url — the same permanent SHA-256 URL the confirmation email links to. Use it as a post-checkout "manage your booking" CTA:
$booking = BoothZen::bookings()->get('bk_01HXYZ');
echo '<a href="' . $booking->customer_portal_url . '">Manage your booking</a>';
Booking deposit, balance and line items (v0.7.3 + v0.7.4)
Booking now exposes a full itemised breakdown:
$booking = BoothZen::bookings()->get('bk_01HXYZ');
// Top-level money fields
echo $booking->deposit_amount->amount; // e.g. 5000 (pence)
echo $booking->remaining_balance->amount; // top-level alias for $totals_breakdown->remaining
// Itemised breakdown
$bd = $booking->totals_breakdown;
echo $bd->subtotal->amount; // base price before tax/discounts
echo $bd->coupon_discount->amount; // 0 if no coupon
echo $bd->tax_total->amount;
echo $bd->deposit->amount;
echo $bd->remaining->amount;
// Package slots
foreach ($booking->packages as $p) {
echo $p->package_id; // "sv_01HDEF"
echo $p->unit_id; // "un_01HPQR" (nullable)
echo $p->unit_price->amount; // per-unit price (pence)
echo $p->event_date; // "2026-08-15"
}
// Extras
foreach ($booking->extras as $e) {
echo $e->name; // "Guestbook"
echo $e->unit_price->amount; // 4900 (pence)
echo $e->quantity; // 2
echo $e->amount->amount; // 9800 (= unit_price × quantity)
echo $e->included; // false (true for zero-priced included extras)
}
The canonical payment_option enum is also now published on bookings()->create() — see v0.7.4 below.
Payment option on bookings (v0.7.4)
// Pass payment_option when creating
$booking = BoothZen::bookings()->create([
'customer_id' => 'cu_01HABC',
'package_id' => 'sv_01HDEF',
'event_date' => '2026-08-15T14:00:00+00:00',
'payment_option' => 'deposit', // 'deposit' | 'full' | 'pay_later'
]);
// Read it back
echo $booking->payment_option; // "deposit"
echo $booking->remaining_balance->amount; // outstanding balance in pence
Payment options awareness (v0.7.5)
/v1/me now tells you which payment options the tenant has enabled, plus their full offline catalogue:
$me = BoothZen::me()->get();
// Online options — render enabled ones as radio buttons
foreach ($me->payment_options as $opt) {
// $opt->value ∈ 'deposit' | 'full' | 'pay_later'
// $opt->label — human label (e.g. "Pay deposit now")
// $opt->requires_online_capture — mount Stripe Elements when true
// $opt->enabled — only show when true
if ($opt->enabled) {
echo "<label><input type=radio name=payment value=\"{$opt->value}\"> {$opt->label}</label>";
}
}
// Offline methods (BACS / cash / pay-on-the-day / etc)
foreach ($me->offline_payment_methods as $m) {
// $m->id ("opm_<n>"), $m->slug, $m->label
// $m->deposit_policy ('full_offline' | 'card_deposit' | 'pay_on_day')
// $m->instructions (nullable operator-set instructions to show the customer)
// $m->requires_online_capture (bool — card_deposit hybrids need Stripe Elements)
}
// Global force-promote window: if event is within this many days, deposit → full
echo $me->balance_due_offset_days; // int | null
Offline-method bookings (v0.7.6)
Pass an offline_payment_method_id from $me->offline_payment_methods when the customer selects an offline method:
$booking = BoothZen::bookings()->create([
'customer_id' => 'cu_01HABC',
'package_id' => 'sv_01HDEF',
'event_date' => '2026-08-15T14:00:00+00:00',
'offline_payment_method_id' => 'opm_2', // from $me->offline_payment_methods
]);
// New status fields on the returned Booking
echo $booking->payment_status; // "awaiting_offline_payment"
echo $booking->offline_payment_method_id; // "opm_2"
echo $booking->awaiting_payment_since; // "2026-08-01T09:00:00Z" (ISO-8601 | null)
echo $booking->deposit_due_at; // "2026-08-08T09:00:00Z" (ISO-8601 | null)
Per-package deposit policy (v0.7.7)
Data\Service now exposes the per-package deposit configuration — useful for rendering a deposit summary before the customer pays:
$service = BoothZen::services()->get('sv_01HDEF');
echo $service->deposit_type; // "flat" | "percent" | "tenant_default" | null
echo $service->deposit_value; // pence for 'flat'; basis-points for 'percent' (25% → 2500); null otherwise
echo $service->balance_due_offset_days; // per-package override; null → fall back to $me->balance_due_offset_days
Advisory only. The authoritative deposit amount comes from
pricing()->preview(). These fields are for UI rendering (e.g. showing "25% deposit required" on the package card) — not for calculating the actual charge.
Validation contract (v0.7.8)
Two new platform-enforced 422 cases to handle at checkout:
Disabled payment option — bookings()->create() returns 422 invalid_param if the requested payment_option isn't enabled for the tenant. deposit and full default to enabled; pay_later defaults to opt-in. Guard with:
use BoothZen\Laravel\Exceptions\ValidationException;
$enabledValues = collect(BoothZen::me()->get()->payment_options)
->where('enabled', true)
->pluck('value')
->all();
if (! in_array($selectedOption, $enabledValues, true)) {
// surface an error to the customer — don't call create()
}
Offline booking + payment intent — paymentIntents()->create() returns 422 payment_intent_not_supported_for_offline_booking when the booking uses a full_offline or pay_on_day method. card_deposit hybrid methods still work. Check $opt->requires_online_capture (or $m->requires_online_capture for offline methods) before mounting Stripe Elements.
Webhook Verification
BoothZen signs webhooks using an HMAC-SHA256 signature in the
BoothZen-Signature header: t=<unix>,v1=<sha256_hex>.
Option A: Middleware (recommended)
Register the route and apply the boothzen-webhook middleware alias:
// routes/api.php
use App\Http\Controllers\WebhookController;
Route::post('/webhooks/boothzen', [WebhookController::class, 'handle'])
->middleware('boothzen-webhook');
The middleware reads config('boothzen.webhook_secret'), verifies the
signature, and returns a structured 401 JSON response on failure. Your
controller only runs on verified payloads.
// app/Http/Controllers/WebhookController.php
use BoothZen\Laravel\Webhooks\Events;
class WebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
// Envelope is Stripe-shape:
// { id, type, created (unix), livemode, data: { object: {...} } }
match ($payload['type'] ?? '') {
Events::BOOKING_CREATED => $this->handleBookingCreated($payload['data']['object']),
Events::PAYMENT_RECEIVED => $this->handlePaymentReceived($payload['data']['object']),
Events::INVOICE_PAID => $this->handleInvoicePaid($payload['data']['object']),
default => null,
};
return response()->json(['received' => true]);
}
}
Option B: Facade (manual controller)
use BoothZen\Laravel\Facades\BoothZen;
use BoothZen\Laravel\Exceptions\WebhookSignatureException;
class WebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
try {
BoothZen::verifyWebhook(
$request->getContent(),
$request->header('BoothZen-Signature', ''),
);
} catch (WebhookSignatureException $e) {
return response()->json(['error' => $e->reason], 401);
}
// Signature valid — process event
return response()->json(['received' => true]);
}
}
Typed event constants
Added in v0.2.0. Stop stringly-typing event names. Webhooks\Events
mirrors the platform's canonical catalogue — a single source of truth for
the 13 events BoothZen emits:
| Constant | String value | When it fires |
|---|---|---|
Events::BOOKING_CREATED |
booking.created |
New booking saved |
Events::BOOKING_UPDATED |
booking.updated |
Existing booking edited |
Events::BOOKING_CANCELLED |
booking.cancelled |
Booking cancelled |
Events::BOOKING_CONFIRMED |
booking.confirmed |
Booking confirmed |
Events::PAYMENT_RECEIVED |
payment.received |
Payment successfully taken (incl. Stripe payment_intent.succeeded) |
Events::PAYMENT_REFUNDED |
payment.refunded |
Payment refunded |
Events::QUOTE_CREATED |
quote.created |
New quote issued |
Events::QUOTE_ACCEPTED |
quote.accepted |
Customer accepted a quote |
Events::QUOTE_DECLINED |
quote.declined |
Customer declined a quote |
Events::LEAD_CREATED |
lead.created |
Lead enquiry captured |
Events::CUSTOMER_CREATED |
customer.created |
New customer record |
Events::INVOICE_SENT |
invoice.sent |
Invoice sent to customer |
Events::INVOICE_PAID |
invoice.paid |
Invoice marked as paid |
Note: there is no
payment.succeededorpayment.failedevent — usePAYMENT_RECEIVEDfor successful captures. There is nobooking.completedevent either; useBOOKING_CONFIRMED.
Helpers on the class:
use BoothZen\Laravel\Webhooks\Events;
Events::all(); // ['booking.created', 'booking.updated', ...]
Events::descriptions(); // ['booking.created' => 'A new booking has been created', ...]
Events::isValid('booking.created'); // true
Events::isValid('payment.succeeded'); // false — not a BoothZen event
Events::groupedByResource(); // ['booking' => [...], 'payment' => [...], ...]
The signing header is BoothZen-Signature: t=<unix>,v1=<sha256_hex>. The
signed string is <timestamp>.<body> (literal dot, not concatenated).
Default tolerance is 300 seconds (5 minutes) — older payloads are
rejected with WebhookSignatureException::REASON_TIMESTAMP_OUT_OF_WINDOW.
Algorithm: hash_hmac('sha256', "$t.$body", $secret), compared with
hash_equals() (constant-time).
Secret rotation
During key rotation, pass both secrets. Requests signed with either secret pass verification:
BoothZen::verifyWebhook(
$payload,
$header,
[config('boothzen.webhook_secret'), config('boothzen.webhook_secret_old')],
);
Testing with BoothZen::fake()
Replace the live connector with a Saloon MockClient before your test runs:
use BoothZen\Laravel\Facades\BoothZen;
use BoothZen\Laravel\Http\Requests\ListBookingsRequest;
use Saloon\Http\Faking\MockResponse;
it('returns the bookings list', function (): void {
BoothZen::fake([
ListBookingsRequest::class => MockResponse::make([
'data' => [
[
'id' => 'bk_test_001',
'customer_id' => 'cu_test_001',
'service_id' => 'sv_test_001',
'unit_id' => null,
'status' => 'confirmed',
'event_date' => '2026-08-15T14:00:00+00:00',
'total' => ['amount' => 12500, 'currency' => 'GBP'],
'created_at' => '2026-01-01T00:00:00+00:00',
'updated_at' => '2026-01-01T00:00:00+00:00',
'is_test' => true,
],
],
'has_more' => false,
'next_cursor' => null,
], 200),
]);
$bookings = BoothZen::bookings()->list();
expect($bookings)->toHaveCount(1);
expect($bookings->data[0]->id)->toBe('bk_test_001');
expect($bookings->data[0]->total->amount)->toBe(12500);
BoothZen::assertSent(ListBookingsRequest::class);
});
Available assertion methods on the BoothZenFake instance returned by fake():
| Method | Description |
|---|---|
BoothZen::assertSent(string|\Closure) |
Assert at least one matching request was sent |
$fake->assertNothingSent() |
Assert no requests were sent |
$fake->assertSentCount(int) |
Assert exact number of requests sent |
$fake->getMockClient() |
Access raw Saloon MockClient for advanced assertions |
Exception Handling
All exceptions extend \BoothZen\Laravel\Exceptions\BoothZenException.
| HTTP Status | Exception Class | Extra Properties |
|---|---|---|
| 401 | AuthenticationException |
— |
| 403 | AuthorizationException |
— |
| 404 | NotFoundException |
— |
| 422 | ValidationException |
->errors (field array), ->param |
| 429 | RateLimitedException |
->retryAfter (int seconds) |
| 5xx | ServerException |
— |
All exceptions expose ->errorCode (string), ->requestId (string|null), ->httpStatus (int).
use BoothZen\Laravel\Facades\BoothZen;
use BoothZen\Laravel\Exceptions\BoothZenException;
use BoothZen\Laravel\Exceptions\NotFoundException;
use BoothZen\Laravel\Exceptions\RateLimitedException;
use BoothZen\Laravel\Exceptions\ValidationException;
try {
$booking = BoothZen::bookings()->get('bk_does_not_exist');
} catch (NotFoundException $e) {
// 404 — log and return 404 to the user
report($e);
abort(404, 'Booking not found');
} catch (ValidationException $e) {
// 422 — show field errors
return back()->withErrors($e->errors);
} catch (RateLimitedException $e) {
// 429 — back off and retry
sleep($e->retryAfter);
// retry the request
} catch (BoothZenException $e) {
// catch-all — unexpected error
report($e);
abort(500, 'BoothZen API error: ' . $e->getMessage());
}
Sandbox Mode
Use a bz_test_ API key to operate in sandbox mode. The SDK automatically
adds BoothZen-Test-Mode: true to every request header when the key starts
with bz_test_. The server rejects test keys against live data and vice versa.
# Sandbox
BOOTHZEN_API_KEY=bz_test_sk_your_test_key
# Production
BOOTHZEN_API_KEY=bz_live_sk_your_live_key
No code changes are needed to switch between environments — only the .env
value changes.
Versioning
This package follows Semantic Versioning.
Pre-1.0 (current): Breaking changes are allowed in minor releases
(0.x → 0.y). Use a specific minor constraint in beta:
composer require boothzen/laravel-sdk:^0.1
v1.0.0 will be tagged once the BoothZen REST API v1 contract reaches general availability. From v1.0 onward, only backwards-compatible changes appear in minor releases.
Links
| Resource | URL |
|---|---|
| OpenAPI spec | https://app.boothzen.com/api/v1/openapi.json |
| Postman collection | https://app.boothzen.com/api/v1/postman.json |
| Developer portal | https://developers.boothzen.com (live with Phase 148) |
| GitHub | https://github.com/leeashcroft6126/boothzen-laravel-sdk |
| Packagist | https://packagist.org/packages/boothzen/laravel-sdk |
| BoothZen dashboard | https://app.boothzen.com |
Contributing
Issues and pull requests are welcome at leeashcroft6126/boothzen-laravel-sdk.
Before opening a PR, ensure CI passes locally:
vendor/bin/pest
vendor/bin/phpstan analyse --no-progress
vendor/bin/pint --test
composer validate --strict
License
MIT. See LICENSE for details.
Copyright (c) 2026 BoothZen / Lee Ashcroft.