Calendar sync, webhook and check-in hooks

Available in:Pro

These HappenBoard Pro hooks run where your site talks to other systems, and at the door. They cover outgoing webhooks and Slack, the Google and Outlook calendar sync, the check-in scanner and badge printing.

The setup side is in Outbound webhooks and QR code check-in. If you are new to hooks, start with Extending HappenBoard with hooks.

Verifying a webhook signature

Every webhook delivery is a POST with a JSON body holding event, delivered_at, delivery_id and data. It carries these headers:

  • X-HappenBoard-Event: the event key, such as rsvp.created.
  • X-HappenBoard-Delivery: an ID that stays the same on every retry of one delivery.
  • X-HappenBoard-Timestamp: the Unix time of this attempt.
  • X-HappenBoard-Signature-V1: t=<timestamp>,v1=<hex>, an HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the webhook’s signing secret.
  • X-HappenBoard-Signature: sha256=<hex>, an HMAC of the body alone. It is still sent for older receivers but gives no protection against replays.

Check the v1 signature in constant time, and refuse a timestamp more than five minutes from your clock. Changing t breaks the signature, so a captured request cannot be replayed later.

Code
// $secret is the webhook's signing secret.
parse_str( str_replace( ',', '&', $_SERVER['HTTP_X_HAPPENBOARD_SIGNATURE_V1'] ?? '' ), $sig );
$t  = (string) ( $sig['t'] ?? '' );
$ok = ctype_digit( $t )
    && abs( time() - (int) $t ) <= 300
    && hash_equals( hash_hmac( 'sha256', $t . '.' . file_get_contents( 'php://input' ), $secret ), (string) ( $sig['v1'] ?? '' ) );

Hash the body exactly as it arrived, before decoding the JSON. Then use X-HappenBoard-Delivery to ignore a delivery you already processed: each retry keeps the ID but gets a fresh timestamp.

The signing secret is shown once: when the webhook is created, and each time someone clicks Generate a new secret in its editor. After that, the webhook list only shows its last four characters. Over REST, POST /happenboard/v1/pro/webhooks answers the new secret once, and POST /happenboard/v1/pro/webhooks/{id}/secret replaces it.

How deliveries are retried and paused

Deliveries leave in a background job, never in the request that triggered them. A failure that looks temporary (no response, a timeout, HTTP 408, 429 or 5xx) is retried 1, 4, 16, 64 and 256 minutes later: six attempts over about six hours. Retries go through Action Scheduler when it is installed, WP-Cron otherwise. Any other answer is final, and redirects are never followed.

Each failed attempt adds one to the webhook’s count of failures in a row, and a 2xx answer sets it back to zero. At 50, Pro switches the webhook off, its waiting retries stop, the list shows it as Paused and the site admin gets an email. A webhook already past the limit is switched off when the Webhooks card lists it. Switching it back on starts the count again.

The Test button sends a webhook.test event at once. It fires the delivery hooks like a real attempt, but is never retried and never counted.

Check-in at the door

  • Each scan calls POST /happenboard/v1/pro/checkin/mark with the ticket’s code, plus the event picked in Event at this door when there is one.
  • A ticket code that fails verification gets HTTP 400 and counts towards the lockout (happenboard_pro_checkin_lockout).
  • A genuine ticket is refused with HTTP 409 when it is for another event, when its booking holds no seat, or when it is for another date of a recurring event. That fires happenboard_pro_attendee_checkin_refused.
  • An accepted scan fires happenboard_pro_attendee_checked_in, documented with the attendee hooks.
  • The scanner’s Undo button deletes that check-in and fires happenboard_pro_attendee_checkin_undone.

The filters that shape the event sent to Google and Outlook are documented with the event hooks.

happenboard_pro_webhook_delivered

Action Pro src/Service/Webhooks/Sender.php

Fires after each outgoing webhook attempt that gets an HTTP response back, whatever the status code. Check $status to tell a success from a refusal.

Signature

do_action( 'happenboard_pro_webhook_delivered', array $webhook, string $event, array $data, int $status, string $body )

Parameters

$webhook array<string,mixed>
The webhook: id, label, url, secret, events, active, createdUtc, updatedUtc and lastResult (utc, status, error and failures of the previous attempt). It holds the signing secret: never log it whole.
$event string
The event key, also sent in X-HappenBoard-Event: rsvp.created, rsvp.cancelled, attendee.status_changed, attendee.checked_in, attendee.checkin_undone, attendee.deleted, or webhook.test for the test ping.
$data array<string,mixed>
The payload sent in the body’s data key. An attendee’s name and email are read from their row for each attempt, so a person erased since the event is sent as the erasure left them.
$status int
HTTP status code of the answer. Only 200 to 299 counts as delivered.
$body string
The raw response body.

Example

add_action(
    'happenboard_pro_webhook_delivered',
    function ( $webhook, $event, $data, $status, $body ) {
        if ( $status >= 200 && $status < 300 ) {
            return; // Delivered.
        }
        // This hook also fires for 3xx, 4xx and 5xx answers.
        error_log( sprintf(
            'Webhook %s to %s answered HTTP %d: %s',
            $event,
            (string) ( $webhook['url'] ?? '' ),
            $status,
            substr( $body, 0, 200 )
        ) );
    },
    10,
    5
);

Notes

  • Five arguments are passed, so register with 10, 5.
  • Despite its name, it fires for every HTTP answer, error codes included, and once per attempt: a receiver answering 500 can fire it six times for one delivery.
  • An attempt stopped before sending fires nothing: a URL that does not resolve to a public address, or a port other than 80, 443 and 8080. The core http_allowed_safe_ports filter can widen that port list.
  • Pro keeps only the latest outcome on the webhook, which the admin list shows. To keep a history of deliveries, log them here.

happenboard_pro_webhook_failed

Action Pro src/Service/Webhooks/Sender.php

Fires when an outgoing webhook attempt gets no HTTP response at all: a DNS failure, a refused connection, a TLS error or the 5-second timeout. It passes the error message.

Signature

do_action( 'happenboard_pro_webhook_failed', array $webhook, string $event, array $data, string $error )

Parameters

$webhook array<string,mixed>
The webhook, with the same keys as in happenboard_pro_webhook_delivered, signing secret included.
$event string
The event key, for example attendee.checked_in, or webhook.test for the test ping.
$data array<string,mixed>
The payload that was being sent in the body’s data key.
$error string
The message of the WP_Error returned by the HTTP client, such as a cURL timeout.

Example

add_action(
    'happenboard_pro_webhook_failed',
    function ( $webhook, $event, $data, $error ) {
        // Pro retries on its own: alert once, on the third failure in a row.
        $failures = (int) ( $webhook['lastResult']['failures'] ?? 0 ) + 1;
        if ( 3 !== $failures ) {
            return;
        }
        wp_mail(
            get_option( 'admin_email' ),
            'Webhook unreachable',
            sprintf( '%s (%s): %s', (string) ( $webhook['url'] ?? '' ), $event, $error )
        );
    },
    10,
    4
);

Notes

  • Four arguments are passed, so register with 10, 4.
  • Pro already retries these failures and fires the hook on each attempt. Use it to alert or log, not to send again, or the receiver gets duplicates.
  • A receiver that answers with an error code, 500 for example, fires happenboard_pro_webhook_delivered instead. Listen to both to see every attempt.
  • For an endpoint that stays down, happenboard_pro_webhook_auto_paused gives you one alert when Pro gives up.

happenboard_pro_webhook_auto_pause_after

Filter Pro src/Service/Webhooks/Repository.php

Filters how many failed delivery attempts in a row switch a webhook off. Return 0 to keep every webhook on, whatever happens.

Signature

apply_filters( 'happenboard_pro_webhook_auto_pause_after', int $attempts )

Parameters

$attempts int
Default 50, a little over eight events’ worth of attempts at six attempts each.

Return value

int Failed attempts in a row after which the webhook is switched off. A negative value counts as 0.

Example

add_filter(
    'happenboard_pro_webhook_auto_pause_after',
    function ( $attempts ) {
        // Our CRM has long maintenance windows: give it more room.
        return 200;
    }
);

Notes

  • Every failed attempt counts, retries included: no response, a non-2xx answer, or a URL refused before sending. A 2xx answer sets the count back to zero. Test pings are not counted.
  • The limit is checked when a failure is recorded, and when the Webhooks card lists the webhooks. A webhook already over the limit, or over a lower value you just set, is switched off the next time the card is opened or a delivery fails.
  • The webhook list reads it too, to tell a webhook switched off for failing (autoPaused, shown as Paused) from one switched off by hand.
  • Switching the webhook back on, in the panel or with PATCH /happenboard/v1/pro/webhooks/{id}, starts the count again.

happenboard_pro_webhook_auto_paused

Action Pro src/Service/Webhooks/Repository.php

Fires once when a webhook is switched off after too many failed deliveries in a row. Pro already uses it to email the site admin address. Hook it to warn someone else: that receiver gets nothing until the webhook is switched back on.

Signature

do_action( 'happenboard_pro_webhook_auto_paused', int $webhook_id, int $failures )

Parameters

$webhook_id int
ID of the webhook that was switched off.
$failures int
Failed deliveries in a row when it was switched off: the limit (happenboard_pro_webhook_auto_pause_after, 50 by default), or more for a webhook that was already past it.

Example

add_action(
    'happenboard_pro_webhook_auto_paused',
    function ( $webhook_id, $failures ) {
        // Pro already emails the site admin: tell the developer too.
        wp_mail(
            '[email protected]',
            'Webhook switched off',
            sprintf(
                'Webhook #%d failed %d times in a row and was switched off. Fix the receiver, then switch it back on in HappenBoard > Integrations > Webhooks (Pro).',
                $webhook_id,
                $failures
            )
        );
    },
    10,
    2
);

Notes

  • Two arguments are passed, so register with 10, 2.
  • It usually runs in the background delivery job, with no current user, right after the delivery hook of the attempt that reached the limit. For a webhook already past the limit, it fires in the admin request that lists the webhooks on the Webhooks card.
  • The webhook is switched off by one conditional database update, so two deliveries failing at the same moment fire it once. Retries still waiting for that webhook stop.
  • It never fires when someone switches a webhook off by hand, or when the limit filter returns 0.

happenboard_pro_slack_webhook_url_valid

Filter Pro src/Settings/Repository.php

Filters whether a URL may be saved as the Slack webhook. Only Slack Incoming Webhook URLs (https://hooks.slack.com/services/…) pass by default; return true to allow a Slack-compatible service such as Mattermost or Rocket.Chat.

Signature

apply_filters( 'happenboard_pro_slack_webhook_url_valid', bool $valid, string $url )

Parameters

$valid bool
Whether the URL matches the Slack Incoming Webhook pattern.
$url string
The URL as typed, with surrounding spaces removed.

Return value

bool True to accept the URL.

Example

add_filter(
    'happenboard_pro_slack_webhook_url_valid',
    function ( $valid, $url ) {
        // Also accept our Mattermost incoming webhooks.
        return $valid || str_starts_with( $url, 'https://chat.example.org/hooks/' );
    },
    10,
    2
);

Notes

  • Two arguments are passed, so register with 10, 2.
  • The URL must still start with https://. Returning true for an http:// URL does not get it saved.
  • It runs when Pro’s settings are saved, from the Slack card or POST /happenboard/v1/pro/settings. A refused URL answers HTTP 400 with happenboard_slack_url_invalid. Every message is still checked for a public host when it is sent.
  • The receiver gets Slack’s message format (text plus Block Kit blocks) and no signature: the URL itself is the secret.

happenboard_pro_attendee_checkin_refused

Action Pro src/REST/Checkin.php

Fires when the scanner turns away a genuine ticket: it is for another event, its booking holds no seat, or it is for another date of a recurring event. Nothing is checked in.

Signature

do_action( 'happenboard_pro_attendee_checkin_refused', int $attendee_id, int $event_id, string $status, int $seat_index, string $source, string $reason, int $scanned_for )

Parameters

$attendee_id int
Attendee whose ticket was scanned.
$event_id int
The event the ticket is for.
$status string
The attendee’s status at the time of the scan, such as going, cancelled, waitlist or declined. Only going admits.
$seat_index int
Seat the ticket is for, counted from 0 (0 also for one QR code covering the whole booking).
$source string
Scan source sent by the scanner, qr by default.
$reason string
wrong_event (the ticket is for another event), status (the booking holds no seat), wrong_date (another date of the series) or date_gone (its date was skipped or removed).
$scanned_for int
The event picked in Event at this door, or 0 when the scanner accepts any event.

Example

add_action(
    'happenboard_pro_attendee_checkin_refused',
    function ( $attendee_id, $event_id, $status, $seat_index, $source, $reason, $scanned_for ) {
        if ( 'wrong_event' !== $reason ) {
            return;
        }
        error_log( sprintf(
            'Ticket of attendee %d (event %d) was scanned at the door of event %d.',
            $attendee_id,
            $event_id,
            $scanned_for
        ) );
    },
    10,
    7
);

Notes

  • Seven arguments are passed. Register with 10, 7 to receive $reason and $scanned_for.
  • The event is checked first, so with wrong_event the status can be anything. A ticket booked on a translation of the event (WPML or Polylang) counts as the same event.
  • It fires only from POST /happenboard/v1/pro/checkin/mark. The verify route and the check-in-attendee ability can refuse a ticket too, but never fire it.
  • The scan answers HTTP 409 with happenboard_pro_attendee_not_admissible and the reason in the error data. Pro’s activity journal records it as attendee.checkin_refused at priority 10.

happenboard_pro_attendee_checkin_undone

Action Pro src/REST/Checkin.php

Fires after a mistaken check-in is undone, when door staff click Undo on the scanner. The check-in row is already deleted.

Signature

do_action( 'happenboard_pro_attendee_checkin_undone', int $checkin_id, int $attendee_id, int $event_id, int $seat_index )

Parameters

$checkin_id int
ID of the check-in that was undone, the one happenboard_pro_attendee_checked_in passed for that scan.
$attendee_id int
Attendee the check-in was for.
$event_id int
Event recorded on the check-in.
$seat_index int
Seat it was for, counted from 0.

Example

add_action(
    'happenboard_pro_attendee_checkin_undone',
    function ( $checkin_id, $attendee_id, $event_id, $seat_index ) {
        // Take back the entry our own door counter added at check-in.
        $count = (int) get_post_meta( $event_id, 'my_door_count', true );
        update_post_meta( $event_id, 'my_door_count', max( 0, $count - 1 ) );
    },
    10,
    4
);

Notes

  • Four arguments are passed, so register with 10, 4.
  • It fires from POST /happenboard/v1/pro/checkin/undo (body { checkin_id }), open to users who can run check-in. A check-in that does not exist, or was already undone, answers HTTP 404 and fires nothing.
  • Pro listens three times: the activity journal adds an attendee.checkin_undone row (priority 10), subscribed webhooks get the attendee.checkin_undone event (20), and Slack posts a message when Check-in undone is ticked (30).
  • The webhook payload is checkin_id, attendee_id, event_id, seat_index and undone_utc. A receiver matches checkin_id with the earlier attendee.checked_in delivery.

happenboard_pro_checkin_lockout

Action Pro src/Service/Checkin/VerifyThrottle.php

Fires when a user is locked out of check-in after too many invalid ticket codes. A lockout means someone is probing for valid codes, or a scanner is misreading.

Signature

do_action( 'happenboard_pro_checkin_lockout', int $actor_id, int $count )

Parameters

$actor_id int
ID of the locked-out user, 0 for a request without a signed-in user.
$count int
Distinct failed codes that triggered the lockout: 10.

Example

add_action(
    'happenboard_pro_checkin_lockout',
    function ( $actor_id, $count ) {
        $user = get_userdata( (int) $actor_id );
        wp_mail(
            get_option( 'admin_email' ),
            'Check-in scanner locked',
            sprintf(
                '%s was locked out of check-in for 5 minutes after %d invalid codes.',
                $user ? $user->display_name : 'An unknown user',
                $count
            )
        );
    },
    10,
    2
);

Notes

  • Two arguments are passed, so register with 10, 2.
  • Ten distinct codes that look like HappenBoard tickets but fail verification, within 60 seconds, lock that user out for 300 seconds. Text that is not a ticket, a code already counted and a refused genuine ticket do not count.
  • While locked out, verification answers HTTP 429 with happenboard_pro_checkin_locked, and the hook does not fire again until the lockout ends.
  • Failures are counted per user across the scanner routes and the verify-ticket and check-in-attendee abilities. To lift a lockout early, delete the happenboard_pro_vt_lock_{user_id} transient.

happenboard_badges_per_sheet

Filter Pro src/Service/Badges/BadgeBuilder.php

Filters how many name badges one badge-sheet PDF holds. A larger selection is split into several sheets, each downloaded in its own request.

Signature

apply_filters( 'happenboard_badges_per_sheet', int $per_sheet )

Parameters

$per_sheet int
Default 150.

Return value

int Badges per sheet. The value is kept between 1 and 1000.

Example

add_filter(
    'happenboard_badges_per_sheet',
    function ( $per_sheet ) {
        // Generous server: fewer, larger PDF files.
        return 300;
    }
);

Notes

  • Only HappenBoard Pro fires it, although its name has no _pro_ part.
  • Badges are laid out 8 per A4 page (2 by 4), one per seat of each booking the door would admit.
  • Each badge costs memory and time: 150 badges take about 4 seconds and 85 MB, 400 about 11 seconds and 280 MB. A sheet too large for the server fails with no file, so raise it only with headroom.
  • Only the badge download route, GET /happenboard/v1/pro/attendees/badges, reads it. The export-badges ability has its own limit of 500.

happenboard_pro_calendar_event_pushed

Action Pro src/Service/Calendar/Push/GooglePusher.php

Fires after a HappenBoard event is written to a connected Google or Outlook calendar, on the first insert and on every later update. Use it to read the provider’s answer, such as the remote event ID or its web link.

Signature

do_action( 'happenboard_pro_calendar_event_pushed', int $post_id, string $provider, array $response )

Parameters

$post_id int
The HappenBoard event that was pushed.
$provider string
google or outlook. One hook serves both providers, so always check it.
$response array<string,mixed>
The decoded answer. Google returns an Event resource (id, etag, htmlLink), Microsoft Graph an event (id, @odata.etag, webLink).

Example

add_action(
    'happenboard_pro_calendar_event_pushed',
    function ( $post_id, $provider, $response ) {
        if ( 'google' !== $provider ) {
            return; // Also fires with 'outlook'.
        }
        update_post_meta(
            (int) $post_id,
            'my_gcal_html_link',
            (string) ( $response['htmlLink'] ?? '' )
        );
    },
    10,
    3
);

Notes

  • Three arguments are passed, so register with 10, 3.
  • Saving an event only queues the push, so the hook usually fires later, in a background job (Action Scheduler or WP-Cron). A manual resync fires it at once. The cancellation sent when an event is trashed, and the deletion, do not fire it.
  • It fires on success only. A push that fails for a temporary reason is retried 5, 15, 60 and 240 minutes later. Temporary means no answer, a 5xx status, or 401, 403, 408, 409, 412, 423 or 429; other failures are not retried. No hook reports a failure.
  • Only published events are pushed, and only once sync is on, the account connected and a target calendar chosen. An event whose own sync setting is off is skipped.

happenboard_pro_gcal_exdates

Filter Pro src/Service/Calendar/Push/EventMapper.php

Filters the dates to leave out of a recurring event pushed to Google Calendar. They become an EXDATE line next to the RRULE.

Signature

apply_filters( 'happenboard_pro_gcal_exdates', array $exdates, int $event_id )

Parameters

$exdates array<int,string>
The dates skipped from the series in HappenBoard, each as its start in ISO 8601 UTC. Empty when no date is skipped.
$event_id int
The HappenBoard event being prepared for the push.

Return value

array<int,string> ISO 8601 UTC date-times, one per date to leave out. Anything that is not an array counts as an empty list.

Example

add_filter(
    'happenboard_pro_gcal_exdates',
    function ( $exdates, $event_id ) {
        // Skipped dates are already listed. Also keep our members-only dates
        // off Google, stored as UTC starts such as 2026-11-02T18:00:00Z.
        $private = get_post_meta( $event_id, 'my_private_dates', true );
        if ( is_array( $private ) ) {
            $exdates = array_merge( $exdates, $private );
        }
        return $exdates;
    },
    10,
    2
);

Notes

  • Two arguments are passed, so register with 10, 2.
  • The list already holds the dates skipped in HappenBoard, so they disappear from the Google copy too. Google only: the Outlook push never reads this filter.
  • Return UTC values. A value that does not parse is dropped, and a value with another offset is not converted: its clock time is read as UTC.
  • It runs for every event pushed, but an event with no repeat rule never gets an EXDATE line, whatever you return: Google refuses one without an RRULE.

happenboard_pro_calendar_local_wins

Action Pro src/Service/Calendar/Pull/GooglePuller.php

Fires when the Google pull finds an event edited on both sides since the last sync and keeps the WordPress copy, because it is the more recent. Use it to log the discarded Google edit.

Signature

do_action( 'happenboard_pro_calendar_local_wins', int $post_id )

Parameters

$post_id int
The HappenBoard event whose WordPress copy was kept.

Example

add_action(
    'happenboard_pro_calendar_local_wins',
    function ( $post_id ) {
        // Log only: Pro is already pushing this event back to Google.
        error_log( sprintf(
            'Google conflict on event %d, WordPress copy kept.',
            (int) $post_id
        ) );
    }
);

Notes

  • One argument, so a plain add_action() is enough.
  • When the Google push is on, Pro already listens at priority 10 and pushes the WordPress version back to Google at once. Do not push it again yourself.
  • It fires only when the WordPress copy is strictly newer. On a tie, the Google version wins and the hook does not fire.
  • The pull runs every 15 minutes on WP-Cron, and when someone starts a manual pull from the sync settings. The discarded Google edit is not passed to you.

happenboard_pro_calendar_local_wins_outlook

Action Pro src/Service/Calendar/Pull/OutlookPuller.php

The Outlook version of happenboard_pro_calendar_local_wins. Fires when the Outlook pull finds an event edited on both sides and keeps the more recent WordPress copy.

Signature

do_action( 'happenboard_pro_calendar_local_wins_outlook', int $post_id )

Parameters

$post_id int
The HappenBoard event whose WordPress copy was kept.

Example

add_action(
    'happenboard_pro_calendar_local_wins_outlook',
    function ( $post_id ) {
        // Log only: Pro is already pushing this event back to Outlook.
        error_log( sprintf(
            'Outlook conflict on event %d, WordPress copy kept.',
            (int) $post_id
        ) );
    }
);

Notes

  • One argument, so a plain add_action() is enough.
  • The hook name is the only sign of the provider: there is no $provider argument. Listen to both hooks to cover both calendars.
  • When the Outlook push is on, Pro already listens at priority 10 and pushes the WordPress version back. Do not push it again yourself.
  • Same rule as on Google: it fires only when the WordPress copy is strictly newer.

happenboard_pro_host_busy_unknown_blocks

Filter Pro src/Service/Calendar/Busy/SlotBusyFilter.php

Filters whether a booking host whose connected calendar could not be read is kept off the offered slots. By default, not knowing when a host is busy counts as not knowing they are free.

Signature

apply_filters( 'happenboard_pro_host_busy_unknown_blocks', bool $blocks, array $hosts )

Parameters

$blocks bool
Default true.
$hosts array<int,int>
User IDs of the hosts whose Google or Outlook calendar could not be read for this period.

Return value

bool True to keep those hosts off the slots, false to offer them on their HappenBoard availability alone.

Example

add_filter(
    'happenboard_pro_host_busy_unknown_blocks',
    function ( $blocks, $hosts ) {
        // Keep offering the front desk account (user 12) even when its
        // calendar cannot be read; every other host stays blocked.
        return array( 12 ) === array_values( $hosts ) ? false : $blocks;
    },
    10,
    2
);

Notes

  • Two arguments are passed, so register with 10, 2.
  • Pro removes busy hosts from each slot’s hosts list on the free plugin’s happenboard_booking_slots filter, at priority 20. A slot disappears only when no host is left.
  • It is called only when a calendar read failed. A failed read is cached for 60 seconds and a successful one for 300, so an outage costs a host a minute of slots at a time.
  • Your answer applies to every host in $hosts, and the same list re-checks a slot when a booking is saved. Returning false can let a booking land on a meeting you could not see. It runs on public booking pages: keep it fast.

Next steps