Looking to hire Laravel developers? Try LaraJobs

laravel-sdk maintained by boothzen

Description
Official Laravel SDK for the BoothZen booking platform REST API.
Author
Last update
2026/05/29 17:52 (dev-main)
License
Downloads
0

Comments
comments powered by Disqus

BoothZen Laravel SDK

Latest Stable Version Total Downloads CI PHP Version License

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

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. Use list($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 optionbookings()->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 intentpaymentIntents()->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.succeeded or payment.failed event — use PAYMENT_RECEIVED for successful captures. There is no booking.completed event either; use BOOKING_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.