Scheduling Webhooks: Pushing Bookings Into The Rest Of Your Stack

Which events to subscribe to, how to handle the same delivery arriving twice, and a worked example of writing a booking into a CRM.

TA The Appntmnts Team August 15, 2026
Article Calendar & Integrations

The Short Version

A webhook lets your scheduling system tell your other systems the moment something happens, instead of them asking repeatedly. This guide covers the booking events worth subscribing to, payload design, why you must handle the same event arriving twice, signature verification, and a worked example of pushing a booking into a CRM.

A webhook is an HTTP request your scheduling system sends to a URL you own, the moment something happens. Someone books, your endpoint gets a POST describing the booking, and you do whatever you need to do with it.

The alternative is polling: asking the API every minute whether anything has changed. Polling is simpler to build and debug, because you control when it runs and you can rerun it. It is also wasteful, since almost every request finds nothing, and it puts a floor under your latency equal to the poll interval.

Webhooks invert that. Nothing happens until something happens, and then it happens immediately. The cost is that you now run a public endpoint that must be reachable, must authenticate what it receives, and must behave correctly when the same message arrives twice. This article covers all three, then works through pushing a booking into a CRM.

Webhooks Versus Polling An API

WebhooksPolling
LatencySecondsUp to one interval
Requests when nothing happensNoneEvery interval, forever
Needs a public endpointYesNo
Recovery after downtimeDepends on retries and replayAutomatic on the next run
OrderingNot guaranteedYou control it
Local developmentNeeds a tunnelWorks anywhere

The honest recommendation for anything important is both. Use webhooks for the fast path, and run a low-frequency reconciliation job that lists recent bookings through the API and repairs anything the webhooks missed. Webhook delivery is best-effort by definition, and a nightly sweep turns "we lost an hour of bookings during an outage" into a non-event.

The Scheduling Events Worth Subscribing To

Subscribe to what you will act on, and nothing else. Every extra event type is more traffic and more code paths to keep correct.

  • Booking created. The workhorse. Create the CRM activity, provision the resource, start an onboarding sequence, post to a channel.
  • Booking rescheduled. Carries both the old and new times. Update rather than duplicate, and be careful that downstream automation triggered by the original time is moved too.
  • Booking cancelled. Free the resource, stop pending automation, and record the reason if one was given.
  • Booking completed. Fires after the appointment ends. The right trigger for a follow-up email, a feedback request, or an invoice.
  • No-show marked. Fires when a human marks the booking as a no-show. Useful for applying a policy consistently rather than by memory. See no show policy and fees.

Two design notes. Rescheduling should be its own event rather than a cancellation followed by a creation, because the latter makes a genuine cancellation indistinguishable from a time change. And completion is a state change over time rather than a user action, so treat its timing as approximate.

Designing The Payload

A useful payload has an envelope and a body. The envelope identifies the delivery and the event; the body describes the thing that happened.

{
  "id": "evt_01J8ZQ4S7M",
  "type": "booking.rescheduled",
  "occurred_at": "2026-07-21T09:14:22Z",
  "api_version": "2026-01-01",
  "data": {
    "booking": {
      "id": "bkg_8f21c4a9",
      "status": "confirmed",
      "starts_at": "2026-07-24T13:00:00Z",
      "ends_at": "2026-07-24T13:45:00Z",
      "timezone": "Europe/London",
      "previous_starts_at": "2026-07-21T14:00:00Z",
      "event_type": { "id": "evt_consult45", "name": "45 Minute Consultation" },
      "host": { "id": "usr_dana", "email": "[email protected]" },
      "invitee": { "name": "Sam Okoro", "email": "[email protected]" },
      "answers": [ { "question": "What would you like to cover?", "answer": "Pricing" } ]
    }
  }
}

What makes that workable:

  • A delivery identifier at the top level. This is what you deduplicate on.
  • A timestamp of the event itself, not of the delivery attempt. You need it to discard stale messages that arrive out of order.
  • A version. Without one, any payload change breaks every consumer.
  • Stable identifiers everywhere, not just display names. Names change; the booking identifier is what you store as your foreign key.
  • Enough to act without a round trip, but not so much that the payload becomes a second API. Anything that might have changed since the event fired should be re-read from the API.

Idempotency: The Same Event Will Arrive Twice

Webhook delivery is at-least-once. Your endpoint will receive duplicates, and not because anything is broken. The usual cause is that you processed a delivery successfully and then your response was lost, so the sender retried something you had already done.

The fix is a unique index, not a careful check. Record every delivery identifier you have seen in a table with a unique constraint. If the insert succeeds, this is new work; if it collides, you have seen it, and you return success without doing anything. Doing this in a single atomic write is what makes it safe against two deliveries arriving at the same instant.

Make the downstream action idempotent too, because that is a second line of defence. Upsert the CRM meeting keyed on the booking identifier rather than inserting a new one. Then even a duplicate that slips through produces the same end state instead of two meetings.

Ordering is the related trap. Retries mean a rescheduled event can arrive after the cancelled event that followed it. Store the event timestamp alongside your record and ignore any message older than what you have already applied.

Verifying The Signature

Your endpoint is a public URL, so anyone can post to it. Verification proves the request came from the sender and was not altered.

The common pattern is a keyed hash: the sender computes an HMAC over a timestamp and the raw request body using a shared secret, and sends both in headers. You recompute it and compare.

public function handle(Request $request)
{
    $raw       = $request->getContent();
    $timestamp = (int) $request->header('X-Webhook-Timestamp');
    $signature = (string) $request->header('X-Webhook-Signature');

    // Reject anything outside a five minute window, so a captured
    // request cannot be replayed later.
    if (abs(time() - $timestamp) > 300) {
        return response()->noContent(400);
    }

    $expected = hash_hmac(
        'sha256',
        $timestamp . '.' . $raw,
        config('services.scheduling.webhook_secret')
    );

    if (! hash_equals($expected, $signature)) {
        return response()->noContent(401);
    }

    // ... verified from here
}

Four things people get wrong:

  • Signing the parsed body. You must hash the exact bytes received. Decoding JSON and re-encoding it will change whitespace or key order and every signature will fail.
  • Comparing with a normal string comparison. Use a constant-time comparison so the failure time does not leak information about the expected value.
  • Omitting the timestamp. Without one, a captured request stays valid forever and can be replayed.
  • Relying on an IP allowlist instead. Sender addresses change, and an allowlist proves nothing about the payload contents.

Support two active secrets when rotating, so you can accept either during the changeover, then retire the old one. And in a framework that checks CSRF tokens on POST, exempt the webhook route, or every delivery will be rejected before your code runs.

Retries, Timeouts And Returning Fast

Senders expect a quick 2xx. If your endpoint is slow, deliveries queue, time out and get retried, and the backlog becomes worse than the original problem. Retries are spaced with an increasing backoff, and an endpoint that fails long enough may be disabled.

So do the minimum in the request: verify the signature, write the raw event to your own storage, enqueue a job, return. Everything else, the CRM call, the email, the provisioning, happens in a background worker where it can retry on its own terms without the sender knowing or caring.

    $payload = json_decode($raw, true);

    // Unique index on delivery_id turns a repeat delivery into a no-op.
    $event = WebhookEvent::firstOrCreate(
        ['delivery_id' => $payload['id']],
        [
            'type'        => $payload['type'],
            'payload'     => $payload,
            'occurred_at' => $payload['occurred_at'],
        ]
    );

    if ($event->wasRecentlyCreated) {
        ProcessSchedulingEvent::dispatch($event->id);
    }

    return response()->noContent(204);

Return a 2xx only once the event is durably stored. Returning success before you have persisted anything means a crash loses the event permanently, since the sender has been told you have it.

Worked Example: Pushing A Booking Into A CRM

The goal: every booking becomes a meeting on the right contact record, reschedules move it, cancellations close it, and no-shows are logged.

Once the delivery is stored and queued, the worker does the mapping:

public function handle(CrmClient $crm): void
{
    $event   = WebhookEvent::findOrFail($this->eventId);
    $booking = $event->payload['data']['booking'];
    $invitee = $booking['invitee'];

    // 1. Find or create the contact. Email is the natural key.
    $contact = $crm->findContactByEmail($invitee['email'])
        ?? $crm->createContact([
            'email'  => $invitee['email'],
            'name'   => $invitee['name'],
            'source' => 'booking-page',
        ]);

    // 2. Upsert on the booking id, never insert blindly.
    match ($event->type) {
        'booking.created',
        'booking.rescheduled' => $crm->upsertMeeting($booking['id'], [
            'contact_id' => $contact->id,
            'subject'    => $booking['event_type']['name'],
            'starts_at'  => $booking['starts_at'],
            'ends_at'    => $booking['ends_at'],
            'owner'      => $booking['host']['email'],
            'notes'      => $this->formatAnswers($booking['answers']),
        ]),
        'booking.cancelled'   => $crm->cancelMeeting($booking['id']),
        'booking.completed'   => $crm->logOutcome($booking['id'], 'held'),
        'booking.no_show'     => $crm->logOutcome($booking['id'], 'no_show'),
        default               => null,
    };

    $event->markProcessed();
}

The decisions that matter here are not in the code. Email as the contact key is pragmatic and will collide occasionally, for instance when colleagues book from a shared inbox address, so decide in advance whether to merge or create. Storing the booking identifier on the CRM meeting is what makes reschedule and cancel work without searching by time. And the intake answers are usually the most valuable part of the payload: intake form design covers asking for the right things.

Testing And Monitoring Your Endpoint

  • Develop against a tunnel that exposes your local server on a public HTTPS URL, and replay real deliveries rather than hand-writing payloads.
  • Keep every raw delivery for a retention window. When someone asks why a booking never reached the CRM, the stored payload answers it in seconds.
  • Alert on failure rate rather than individual failures, and alert loudly if the endpoint gets disabled.
  • Test the duplicate path by sending the same delivery twice and confirming one meeting exists, and the signature path by sending a wrong signature and confirming a rejection.

When To Poll Instead

Webhooks are the wrong tool when you cannot expose an endpoint, when you need a guaranteed complete picture rather than a stream of changes, or when the consumer is a spreadsheet or a scheduled report. Read the API on a timer instead, filtering on last modified so each run fetches only what changed.

Most real integrations use both: webhooks to react quickly, and reconciliation to guarantee correctness.

Getting Started In appntmnts

appntmnts sends webhooks for booking created, rescheduled, cancelled, completed and no-show, with signed payloads and automatic retries, alongside a REST API for reads and reconciliation. Endpoint setup, the payload reference and the signing scheme are documented for developers on the developer page.

If you are wiring this up for the first time, start with booking created going to one destination and get the duplicate handling right before adding event types. And if you are also letting clients move their own appointments, subscribe to the reschedule event early, because that is the one that reveals whether your integration updates records or duplicates them: see self service rescheduling.

TA

The Appntmnts Team

Scheduling And Calendars, Appntmnts

Share

Put This Into Practice

Get Your Own Booking Page Free

Claim appntmnts.io/yourname, connect a calendar, and share one link. Unlimited bookings, no card at signup, no trial countdown.

Start Free


Free Forever, No Card

Turn This Into A Booking Page In Two Minutes

Claim your link at appntmnts.io/yourname, connect Google, Outlook, Apple iCloud or CalDAV, and let people book real time in your calendar. Unlimited bookings on the free plan, with the limits written out before you sign up.

What You Get At $0

  • A Personal Booking Page With Unlimited Bookings
  • Two-Way Sync With Google, Outlook, iCloud Or CalDAV
  • Automatic Confirmations, Reminders And Timezone Detection
  • Meet And Teams Links Plus The REST API
Compare All Plans