Appointment booking runs through two services. SlotGenerator computes the offered times, and BookingService re-validates the chosen one, picks a host and writes the row. Each exposes one filter, and the write path fires four lifecycle actions.
One rule applies to the whole page: the slot list is generated a second time inside the write path. Whatever happenboard_booking_slots removes can no longer be booked, and whatever it adds becomes bookable.
happenboard_booking_approved
Fires when you approve a booking that was waiting for your answer on a booking type set to require approval. The built-in notifier listens here to send the confirmation email that was withheld at request time.
Signature
do_action( 'happenboard_booking_approved', int $booking_id )
Parameters
-
$booking_idint - Booking primary key.
Return value
void
Example
add_action(
'happenboard_booking_approved',
function ( $booking_id ) {
acme_notify_ops( 'Booking approved', $booking_id );
}
);
Notes
happenboard_booking_confirmedfires immediately after, so anything that only cares that a booking is now real should listen there instead and it will catch every path.- Approving twice is refused before this fires, so a listener never sees the same booking approved more than once.
happenboard_booking_assignment_strategy
Replaces the object that decides which host gets a booking. It runs inside BookingService::create(), after the requested slot has been re-validated and the candidate host list narrowed, and before the row is inserted.
Signature
apply_filters( 'happenboard_booking_assignment_strategy', \HappenBoard\Service\Booking\Assignment\Strategy $strategy, string $assignment )
Parameters
-
$strategy\HappenBoard\Service\Booking\Assignment\Strategy - The resolved default:
LeastLoaded(fewest upcoming bookings) when the booking type is set toround_robin,Fixed(first candidate) for every other value. -
$assignmentstring - The booking type’s configured mode, read from the
_happenboard_bt_assignmentmeta:fixed,round_robin,collective,weightedorpriority. Empty meta falls back toround_robin.
Return value
\HappenBoard\Service\Booking\Assignment\Strategy
An object implementing HappenBoard\Service\Booking\Assignment\Strategy, whose pick() returns the chosen WP user ID or 0.
Example
add_filter(
'happenboard_booking_assignment_strategy',
function ( $strategy, $assignment ) {
if ( 'round_robin' !== $assignment ) {
return $strategy;
}
// Always give the booking to host 12 when they are free at the
// slot; otherwise defer to HappenBoard's least-loaded strategy.
return new class( $strategy ) implements \HappenBoard\Service\Booking\Assignment\Strategy {
private $fallback;
public function __construct( $fallback ) {
$this->fallback = $fallback;
}
public function pick( array $candidate_host_ids, int $booking_type_id, string $slot_utc ): int {
$senior = 12;
if ( in_array( $senior, array_map( 'intval', $candidate_host_ids ), true ) ) {
return $senior;
}
return $this->fallback->pick( $candidate_host_ids, $booking_type_id, $slot_utc );
}
};
},
10,
2
);
Notes
- Two arguments are passed: register with
add_filter( ..., 10, 2 )or you never see$assignment. - The calling method declares a return type of
Strategy. Returning an array, a closure ornullthrows aTypeErrorand the booking submission fails with a 500. - Nothing re-checks the user ID your
pick()returns. Returning0makescreate()answerhappenboard_booking_no_host(HTTP 409); returning an ID that is not in$candidate_host_idswrites it tohost_user_idunchecked, which is how you create a double booking. - The free plugin maps only
round_robintoLeastLoaded.collective,weightedandpriorityall fall through toFixed. The docblock says Pro supplies those three strategies, but no code in HappenBoard Pro registers a callback on this filter, so today they behave exactly likefixed. - It only runs on creation.
BookingService::reschedule()never calls the strategy resolver, so moving a booking keeps its original host even when that host is not free at the new time.
happenboard_booking_awaiting_payment
Decides whether a booking must be paid online before it counts. Free always answers no, because it has no payment rail: a price on a booking type is a statement of what the visitor will owe on the day. HappenBoard Pro answers yes when its Stripe path is configured for that booking type.
Signature
apply_filters( 'happenboard_booking_awaiting_payment', bool $awaiting, int $booking_type_id, array $config )
Parameters
-
$awaitingbool falseby default.-
$booking_type_idint - The booking type being booked.
-
$configarray<string,mixed> - Resolved booking-type config, including
price_cents,requires_approvaland the scheduling rules.
Return value
bool
True to write the booking as pending_payment, which holds the slot while the customer pays.
Example
add_filter(
'happenboard_booking_awaiting_payment',
function ( $awaiting, $booking_type_id, $config ) {
// Only ask for payment above a threshold.
return $config['price_cents'] >= 5000;
},
10,
3
);
Notes
- Three arguments: register with
add_filter( ..., 10, 3 ). - It is never consulted for a booking an operator entered from the admin, nor on a booking type that requires approval. Charging for a slot you might then decline is a refund waiting to happen, so those two flows stay mutually exclusive.
- Returning true without also supplying a URL through
happenboard_booking_payment_urlleaves the customer holding a slot they cannot pay for.
happenboard_booking_cancelled
Fires once a booking row’s status has been switched to cancelled in the database. Both the public cancellation link and the admin cancel action reach it.
Signature
do_action( 'happenboard_booking_cancelled', int $booking_id )
Parameters
-
$booking_idint - Primary key of the cancelled booking in the
happenboard_bookingstable.
Example
add_action(
'happenboard_booking_cancelled',
function ( $booking_id ) {
$repository = new \HappenBoard\Service\Booking\BookingRepository();
$booking = $repository->find_by_id( (int) $booking_id );
if ( null === $booking ) {
return;
}
error_log( sprintf(
'Booking %d cancelled (host %d, start %s)',
(int) $booking['booking_id'],
(int) $booking['host_user_id'],
(string) $booking['start_utc']
) );
}
);
Notes
- There is no docblock on either call, so the auto-extracted signature shows no arguments. That is wrong: one argument is passed.
- Two call sites, same single argument.
BookingService::cancel_by_token()servesDELETE /happenboard/v1/bookings/{token}, andREST\Bookings::admin_update()servesPATCH /happenboard/v1/bookings/{id}withstatus=cancelled. Handle both. - Only the ID is passed. If you need the customer, host or start time, read the row yourself with
BookingRepository::find_by_id(); it is already saved with the new status. - The action fires only after the repository reports the UPDATE succeeded, so a failed cancellation (HTTP 500) never reaches your callback.
- The free plugin listens at priority 10 (
BookingNotifier::on_cancelled): it sends the cancellation email and clears the booking’s pending reminders. A callback at a lower priority that throws will prevent both.
happenboard_booking_client_ip
Overrides the client IP the public booking submission works from. It runs once per POST /happenboard/v1/bookings, right after the nonce check and before any throttling. Use it when the site sits behind a reverse proxy or CDN.
Signature
apply_filters( 'happenboard_booking_client_ip', string $ip )
Parameters
-
$ipstring REMOTE_ADDRaftersanitize_text_field()andfilter_var( ..., FILTER_VALIDATE_IP ), or''when it is absent or not a valid IP.
Return value
string
An IP address as a string. The caller casts the result with (string) and does not validate it further.
Example
add_filter(
'happenboard_booking_client_ip',
function ( $ip ) {
// Only do this when every request really is proxied by Cloudflare.
if ( empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
return $ip;
}
$forwarded = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
return filter_var( $forwarded, FILTER_VALIDATE_IP ) ? $forwarded : $ip;
}
);
Notes
- The docblock calls it the IP used by the rate limiter, but the returned value feeds three consumers: the per-IP throttle (8 submissions per minute, keyed on
md5( $ip )), the Cloudflare Turnstile verification call, and theuser_ipfield sent to Akismet. - Returning
''switches the per-IP throttle off completely: that whole block is wrapped inif ( '' !== $ip ). The per-email throttle (5 per hour) still applies. - Validate anything you read from a proxy header before returning it. The plugin only casts to string, and the value then goes into a transient key and out to two third-party services.
- Only the booking submission uses it.
GET /happenboard/v1/booking-types/{id}/slotsreadsREMOTE_ADDRdirectly insrc/REST/BookingSlots.phpand is not filterable. RSVP submissions have their ownhappenboard_rsvp_client_ip.
happenboard_booking_confirmed
Fires on the line right after happenboard_booking_created, inside BookingService::create(). There is no separate confirmation step in the free plugin, so in practice it fires on every successful booking creation.
Signature
do_action( 'happenboard_booking_confirmed', int $booking_id )
Parameters
-
$booking_idint - Primary key returned by the insert.
Example
add_action(
'happenboard_booking_confirmed',
function ( $booking_id ) {
$booking = ( new \HappenBoard\Service\Booking\BookingRepository() )
->find_by_id( (int) $booking_id );
if ( null === $booking ) {
return;
}
update_user_meta(
(int) $booking['host_user_id'],
'my_last_confirmed_booking',
(int) $booking_id
);
}
);
Notes
- There is no docblock, so the auto-extracted signature lists no arguments. One argument is passed.
- Unlike
happenboard_booking_createdit carries only the ID, not the saved row. Read the row withBookingRepository::find_by_id()if you need it. - Nothing in HappenBoard or HappenBoard Pro listens on it. It exists purely as an extension seam, which also means no other callback is competing with you on priority.
- Do not read it as “payment received”. Rows are always inserted with status
confirmed, and a priced booking type getspayment_status = on_sitein the free create path, neverpending_payment. - If you only need one hook, prefer
happenboard_booking_created: it fires first, on the same code path, and hands you the row.
happenboard_booking_created
Fires after the booking row has been inserted and read back, at the end of BookingService::create(). This is the seam to use for CRM sync, provisioning a meeting link, or anything that needs the full saved booking.
Signature
do_action( 'happenboard_booking_created', int $booking_id, array<string,mixed> $data )
Parameters
-
$booking_idint - Primary key of the inserted row.
-
$dataarray<string,mixed> - The persisted row, re-read with
SELECT *:booking_id,booking_type_id,host_user_id,customer_user_id,customer_email,customer_name,customer_tz,start_utc,end_utc,status,quantity,payment_status,order_id,custom_fields(JSON string),rescheduled_from,cancel_token,created_utc,updated_utc.
Example
add_action(
'happenboard_booking_created',
function ( $booking_id, $data ) {
// $data is the raw DB row: it contains cancel_token. Never forward
// the whole row to a third party.
$payload = array(
'id' => (int) $booking_id,
'start' => (string) ( $data['start_utc'] ?? '' ),
'host' => (int) ( $data['host_user_id'] ?? 0 ),
'quantity' => (int) ( $data['quantity'] ?? 1 ),
);
wp_remote_post(
'https://example.com/crm/bookings',
array(
'timeout' => 5,
'body' => wp_json_encode( $payload ),
'headers' => array( 'Content-Type' => 'application/json' ),
)
);
},
10,
2
);
Notes
- Two arguments: register with
add_action( ..., 10, 2 ). $datacontainscancel_token, the 64-character secret that lets anyone cancel or reschedule the booking through the public REST routes. The submit response deliberately never returns it. Do not log the row, mail it, or forward it to a third party.- The row comes from
$wpdb->get_row( ..., ARRAY_A ), so every column arrives as a string, includingbooking_idandquantity. Cast before comparing, and note thatcustom_fieldsis still JSON-encoded. $datais(array) $saved. If the read-back fails,$savedisnulland you receive an empty array, not a row with empty values. Guard on the keys you use.- The free plugin listens at priority 10 (
BookingNotifier::on_created): confirmation email with an.icsattachment, then reminder scheduling. The docblock says Pro listens here for payment, calendar write and SMS/WhatsApp, but no code in HappenBoard Pro registers a callback on this hook.
happenboard_booking_declined
Fires when you decline a booking request. A decline is a cancellation with a different cause, so the slot is freed and the customer gets the cancellation email they would expect.
Signature
do_action( 'happenboard_booking_declined', int $booking_id )
Parameters
-
$booking_idint - Booking primary key.
Return value
void
Example
add_action(
'happenboard_booking_declined',
fn ( $booking_id ) => acme_log( 'declined', $booking_id )
);
Notes
happenboard_booking_cancelledfires immediately after, which is what sends the cancellation email.
happenboard_booking_location_labels
Filters the visitor-facing label for each booking-type location mode. Use it to name the custom mode, which has no default label, or to relabel the built-in three.
Signature
apply_filters( 'happenboard_booking_location_labels', array $labels )
Parameters
-
$labelsarray<string,string> - Slug to label. Defaults:
in_personto “In person”,virtualto “Online”,phoneto “By phone”.
Return value
array<string,string>
The labels to print. A mode with no entry prints nothing.
Example
add_filter(
'happenboard_booking_location_labels',
function ( $labels ) {
$labels['custom'] = 'At your premises';
return $labels;
}
);
Notes
- Applies to both the Booking Page and the Booking Availability block, so one filter covers every surface that names a location mode.
happenboard_booking_paid
Fires when the payment for a held booking lands and the booking is confirmed. The payment counterpart of approving a request.
Signature
do_action( 'happenboard_booking_paid', int $booking_id )
Parameters
-
$booking_idint - Booking primary key.
Return value
void
Example
add_action(
'happenboard_booking_paid',
fn ( $booking_id ) => acme_issue_receipt( $booking_id )
);
Notes
happenboard_booking_confirmedfires immediately after, on purpose: an extension that already subscribes to “this booking is now real” should not have to learn a second name because the money arrived by card.- Payment providers deliver webhooks at least once and retry, so a booking that is already confirmed is treated as a success with nothing left to do. This action does not fire twice for one appointment.
happenboard_booking_payment_abandoned
Fires when a booking whose payment never arrived is released and its slot goes back on offer.
Signature
do_action( 'happenboard_booking_payment_abandoned', int $booking_id )
Parameters
-
$booking_idint - Booking primary key.
Return value
void
Example
add_action(
'happenboard_booking_payment_abandoned',
fn ( $booking_id ) => acme_metrics( 'checkout_abandoned', $booking_id )
);
Notes
- Abandoning a checkout is not a cancellation the customer should hear about, since they never had a booking.
happenboard_booking_cancelleddeliberately does not fire, so no cancellation email goes out.
happenboard_booking_payment_hold_hours
Filters how long an unpaid booking may hold its slot before the sweeper releases it. This is the backstop for a payment webhook that never arrives: a dropped delivery would otherwise leave an appointment slot off the calendar for good.
Signature
apply_filters( 'happenboard_booking_payment_hold_hours', int $hours )
Parameters
-
$hoursint - Hold window in hours. Defaults to
24.
Return value
int
Hours to hold. Floored at 1.
Example
add_filter( 'happenboard_booking_payment_hold_hours', fn () => 6 );
Notes
- Lower it only in step with your payment provider’s checkout session lifetime. A window shorter than the session lets a customer pay for a slot that has already been given away.
happenboard_booking_payment_pending
Fires when a booking is holding its slot while the customer pays. The slot is blocked from that moment, so the time is still theirs when they come back from checkout.
Signature
do_action( 'happenboard_booking_payment_pending', int $booking_id )
Parameters
-
$booking_idint - Booking primary key.
Return value
void
Example
add_action(
'happenboard_booking_payment_pending',
fn ( $booking_id ) => acme_metrics( 'checkout_started', $booking_id )
);
Notes
- Named
_payment_pending, not_awaiting_payment, which is the filter above. WordPress keeps actions and filters in one namespace, so the two cannot share a name.
happenboard_booking_payment_url
Filters the address a customer must be sent to in order to pay for a booking held pending payment. HappenBoard Pro returns a Stripe hosted-checkout URL.
Signature
apply_filters( 'happenboard_booking_payment_url', string $url, int $booking_id, array $booking )
Parameters
-
$urlstring - Empty by default.
-
$booking_idint - Booking primary key.
-
$bookingarray<string,mixed> - The persisted row, redacted.
Return value
string
An absolute URL, or an empty string.
Example
add_filter(
'happenboard_booking_payment_url',
fn ( $url, $booking_id ) => $url ?: acme_checkout_link( $booking_id ),
10,
2
);
Notes
- Only consulted when
happenboard_booking_awaiting_paymentreturned true. - An empty return means whatever said the booking needs paying cannot actually take the payment, so treat it as a misconfiguration rather than a silent no-op.
happenboard_booking_requested
Fires when a booking arrives as a request awaiting your approval, rather than as a confirmation. Use it to alert whoever has to answer.
Signature
do_action( 'happenboard_booking_requested', int $booking_id )
Parameters
-
$booking_idint - Booking primary key.
Return value
void
Example
add_action(
'happenboard_booking_requested',
function ( $booking_id ) {
wp_mail( get_option( 'admin_email' ), 'New booking request', 'Booking #' . $booking_id . ' is waiting.' );
}
);
Notes
happenboard_booking_confirmeddeliberately does not fire for a request: a booking still waiting on your answer is not confirmed, and listeners on that seam must not act as though it were.happenboard_booking_createdfires first, for every booking whatever its status.
happenboard_booking_rescheduled
Fires after a booking’s start and end have been moved, at the end of BookingService::reschedule(). The new time has already passed the same live-slot check a fresh booking goes through.
Signature
do_action( 'happenboard_booking_rescheduled', int $booking_id, string $old_start, string $new_start )
Parameters
-
$booking_idint - Primary key of the moved booking.
-
$old_startstring - The previous
start_utc, read from the row before the update. UTCY-m-d H:i:s, or''when the column was empty. -
$new_startstring - The new start, normalized to UTC
Y-m-d H:i:s. The end time is derived from the booking type’s duration and is not passed.
Example
add_action(
'happenboard_booking_rescheduled',
function ( $booking_id, $old_start, $new_start ) {
// Both timestamps are UTC 'Y-m-d H:i:s'.
$old = new DateTimeImmutable( $old_start, new DateTimeZone( 'UTC' ) );
$new = new DateTimeImmutable( $new_start, new DateTimeZone( 'UTC' ) );
error_log( sprintf(
'Booking %d moved from %s to %s (site time)',
(int) $booking_id,
$old->setTimezone( wp_timezone() )->format( 'Y-m-d H:i' ),
$new->setTimezone( wp_timezone() )->format( 'Y-m-d H:i' )
) );
},
10,
3
);
Notes
- Three arguments: register with
add_action( ..., 10, 3 ). There is no docblock, so the auto-extracted signature lists none at all. - Both timestamps are bare UTC
Y-m-d H:i:sstrings with no offset suffix. The customer’s own timezone stays in the row’scustomer_tzcolumn. - The host is not reassigned.
reschedule()never calls the assignment strategy, sohost_user_idkeeps its original value even if that host is not among the hosts free at the new time. - The reschedule path checks that the new slot is offered but, unlike creation, never compares
capacity_leftagainst the booking’squantity. A group booking can be moved onto a slot with fewer seats left than it needs. - Reachable only from
PATCH /happenboard/v1/bookings/{token}/reschedule. The admin routePATCH /happenboard/v1/bookings/{id}supports cancellation only, so an admin-side move does not exist yet and cannot fire this.
happenboard_booking_slots
The last step of the availability engine, after weekly windows, days off, notice and advance limits, buffers and capacity have all been applied. Every offered time for a booking type passes through here, so this is the single place to add or remove bookable slots.
Signature
apply_filters( 'happenboard_booking_slots', array<int,array<string,mixed>> $list, int $booking_type_id, string $from, string $to )
Parameters
-
$listarray<int,array<string,mixed>> - Zero-indexed and sorted by UTC start. Each row has
start_utcandend_utc(UTCY-m-d H:i:s),start(ISO 8601 in the requester’s timezone),label(display string),hosts(WP user IDs free at that time) andcapacity_left(seats remaining). -
$booking_type_idint - The
hboard_booking_typepost ID the slots belong to. -
$fromstring - The window start exactly as it reached the generator, before parsing and clamping. From the REST route it is the raw querystring value and can be
''. -
$tostring - The window end, same handling as
$from.
Return value
array<int,array<string,mixed>>
A zero-indexed list of slot arrays. The result is cast with (array) and returned as-is to the caller.
Example
add_filter(
'happenboard_booking_slots',
function ( $slots, $booking_type_id, $from, $to ) {
if ( 42 !== $booking_type_id ) {
return $slots;
}
// Remove: nothing on Friday afternoon, site timezone.
$slots = array_filter(
$slots,
function ( $slot ) {
$local = ( new DateTimeImmutable( $slot['start_utc'], new DateTimeZone( 'UTC' ) ) )
->setTimezone( wp_timezone() );
return ! ( 'Fri' === $local->format( 'D' ) && (int) $local->format( 'G' ) >= 12 );
}
);
// Add: an extra 09:00 UTC slot tomorrow, hosted by user 7.
$start = new DateTimeImmutable( 'tomorrow 09:00', new DateTimeZone( 'UTC' ) );
$slots[] = array(
'start_utc' => $start->format( 'Y-m-d H:i:s' ),
'end_utc' => $start->modify( '+30 minutes' )->format( 'Y-m-d H:i:s' ),
'start' => $start->format( 'c' ),
'label' => $start->format( 'D, M j H:i' ),
'hosts' => array( 7 ),
'capacity_left' => 1,
);
// Always hand back a zero-indexed list.
return array_values( $slots );
},
10,
4
);
Notes
- Four arguments: register with
add_filter( ..., 10, 4 ). - This filter also gates writes.
BookingService::create()and::reschedule()call the generator again at submit time and reject any start that is not in the returned list, so removing a slot really blocks the booking. A slot you invent becomes bookable, provided it carriesstart_utcin exactly the UTCY-m-d H:i:sformat, ahostsarray of real WP user IDs and acapacity_leftat least equal to the requested quantity. Otherwise the submission fails withhappenboard_booking_slot_unavailableorhappenboard_booking_no_host(HTTP 409). - Return a list, not a map. The array is serialized straight into the
slotskey ofGET /happenboard/v1/booking-types/{id}/slots; a non-sequential array becomes a JSON object and breaks the front end. Finish witharray_values(), as the Pro callback does. - The arguments differ on the write path:
find_live_slot()calls the generator asfor_type( $id, $day, $day, 'UTC' ), so$fromand$toare the sameY-m-dday and the display fields are built in UTC rather than the customer’s timezone. Derive your logic fromstart_utc, not from$fromand$to. Note also that the generator stops collecting at 750 slots before the filter runs, so a wide window can hand you a truncated list. - HappenBoard Pro already registers a callback at priority 10 with 4 arguments (
Service\Resources\ConflictResolver::filter_slots), which drops slots whose shared resources are fully committed. Use a priority above 10 if you need the final set. The generator’s own docblock also claims Pro subtracts external calendar busy times here; only the shared-resource callback exists in HappenBoard Pro.
happenboard_currency
Filters the site display currency, as an uppercase ISO 4217 code. This is the single override point for every amount HappenBoard prints, which in the free plugin means the price on a booking type.
Signature
apply_filters( 'happenboard_currency', string $code )
Parameters
-
$codestring - The code saved under Settings → General.
USDby default.
Return value
string
A three-letter code. It is normalised, so a lowercase or padded value is accepted.
Example
add_filter( 'happenboard_currency', fn () => 'EUR' );
Notes
- It changes how amounts are written, not what they are worth. Nothing is converted, so switching currency relabels existing prices.
- Read every amount through this rather than the raw setting, or your integration and the rest of the plugin will disagree.
happenboard_slot_cache_ttl
Filters how long a computed set of bookable slots is reused before it is worked out again.
Signature
apply_filters( 'happenboard_slot_cache_ttl', int $ttl )
Parameters
-
$ttlint - Lifetime in seconds. Defaults to 5 minutes.
Return value
int
Seconds to cache. Return 0 to disable slot caching entirely.
Example
// A shared-room calendar that changes constantly.
add_filter( 'happenboard_slot_cache_ttl', fn () => 60 );
Notes
- The cache is only a display accelerator. The chosen slot is re-validated inside the write path, so a stale cache can offer a time that is then refused, never double-book one.
- Returning 0 makes every booking page recompute availability on each load, which is correct but noticeably more work on a busy calendar.