Outbound events

Webhooks

Lola Dispatch ships outbound events as signed Standard Webhooks. Every delivery carries the spec's three headers, a base64 HMAC-SHA256 signature over a deterministic payload string, and an event-id you must dedupe on.

Standard Webhooks adoption

Lola Dispatch adopted the Standard Webhooks spec in V5 (2026-05-26). See the spec for the canonical signing algorithm — the rest of this page is a Lola-Dispatch-flavoured cookbook of it.

The legacy X-LolaDispatch-Signature hex HMAC header was removed at the same cutover. There is no dual-emit; receivers must verify the Standard Webhooks header triple below.

Headers triple

Every delivery carries these three HTTP headers. Verifiers MUST read them case-insensitively and reject any delivery missing one.

webhook-id
Format: evt_<uuid>. The deduplication key. Replays carry the same id; dedupe on this before any side-effect.
webhook-timestamp
Unix seconds (integer, string-encoded). Reject deliveries whose timestamp drifts more than ±5 minutes from your server clock — that is the spec's default replay window.
webhook-signature
Format: v1,<base64>. A space-delimited list during rotation (see below) — verifiers MUST accept any of the listed signatures.

Signed payload format

The signature is HMAC-SHA256, base64-encoded, over the following string:

${webhook_id}.${webhook_timestamp}.${rawBody}

Use the raw request body, byte-for-byte. Re-serialising the JSON breaks the signature.

Verification samples

Use the official standardwebhooks library where one exists for your stack. The manual recipes are here for languages without a library and for one-off debugging.

Node — standardwebhooks

Install via npm install standardwebhooks. The library handles header parsing, timestamp tolerance, and multi-signature rotation automatically.

Node
import { Webhook } from 'standardwebhooks';

// secret is the value Lola issues on /clients/developer-settings (whsec_…).
const wh = new Webhook(process.env.LOLA_WEBHOOK_SECRET!);

export function handleLolaWebhook(rawBody: string, headers: Record<string, string>) {
  // Throws on signature mismatch, missing headers, or timestamp drift > 5 minutes.
  const event = wh.verify(rawBody, headers);

  // event.type, event.data — see the taxonomy table below.
  return event;
}

Python — stdlib hmac

The spec is simple enough that the Python stdlib is sufficient. Constant-time compare via hmac.compare_digest. A community-maintained standardwebhooks package also exists on PyPI if you prefer to lean on a library.

Python
import hmac
import hashlib
import base64
import time

TOLERANCE_SECONDS = 5 * 60  # ±5 minutes, per the spec.

def verify_lola_webhook(secret: str, raw_body: str, headers: dict) -> dict:
    webhook_id = headers["webhook-id"]
    webhook_timestamp = headers["webhook-timestamp"]
    webhook_signature = headers["webhook-signature"]

    # Timestamp tolerance (replay window).
    if abs(int(time.time()) - int(webhook_timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("webhook timestamp outside tolerance")

    # Strip the optional "whsec_" prefix before base64-decoding.
    secret_bytes = base64.b64decode(
        secret[len("whsec_") :] if secret.startswith("whsec_") else secret
    )

    signed_payload = f"{webhook_id}.{webhook_timestamp}.{raw_body}".encode()
    expected = base64.b64encode(
        hmac.new(secret_bytes, signed_payload, hashlib.sha256).digest()
    ).decode()

    # webhook-signature may carry multiple "v1,<sig>" entries space-delimited
    # during a 24h rotation window — accept either.
    candidates = [
        part.split(",", 1)[1]
        for part in webhook_signature.split(" ")
        if part.startswith("v1,")
    ]
    if not any(hmac.compare_digest(expected, c) for c in candidates):
        raise ValueError("webhook signature mismatch")

    import json
    return json.loads(raw_body)

curl + openssl — manual one-off

For language-less debugging. Reproduce the expected signature, compare it against the webhook-signature header sent.

bash
# Manual verification with openssl — useful for one-off debugging.
# Replace WEBHOOK_SECRET, WEBHOOK_ID, WEBHOOK_TIMESTAMP, and BODY accordingly.

WEBHOOK_SECRET='whsec_AbCd...'      # value Lola issued on /clients/developer-settings
WEBHOOK_ID='evt_8c7b5d3a-...'       # value of the webhook-id header
WEBHOOK_TIMESTAMP=1716700000        # value of the webhook-timestamp header
BODY='{"type":"job.confirmed",...}' # exact raw request body, byte-for-byte

# Strip the whsec_ prefix and base64-decode the secret bytes.
SECRET_BYTES=$(printf '%s' "${WEBHOOK_SECRET#whsec_}" | base64 -d | xxd -p -c 256)

# Compute the expected signature.
printf '%s.%s.%s' "$WEBHOOK_ID" "$WEBHOOK_TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$SECRET_BYTES" -binary \
  | base64

Minimal verification + payload

The shortest verification path: hand the raw body and headers to the standardwebhooks library, read event.data.

Node
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.LOLA_WEBHOOK_SECRET!);
const event = wh.verify(rawBody, headers); // throws on mismatch
return event.data; // the booking payload below

A representative event.data for a freshly-created WEEKLY_INVOICE booking:

JSON
{
  "jobId": "ckxyz123",
  "status": "CONFIRMED",
  "fulfillmentChannel": "MOBILE",
  "customerPostcode": "SW1A 1AA",
  "requestedDate": "2026-06-01T09:00:00.000Z"
}

Multi-signature rotation

During a 24-hour rotation transition Lola Dispatch concatenates both signatures in the single webhook-signature header, space-delimited:

webhook-signature: v1,<old-base64> v1,<new-base64>

Consumers SHOULD accept either; the standardwebhooks library does this automatically. The rotation window is exactly 24 hours; the old secret stops signing at that point.

Subscription filtering

Each client picks the event types its endpoint subscribes to via ClientCapability.webhookEventSubscriptions (a String[]in Postgres). Events not in the list are not delivered. The default is "all events" — every type in the taxonomy table below.

Manage subscriptions on clients.loladispatch.com/developer-settings.

Replay and idempotency

Lola Dispatch ships at-least-once delivery. Every event carries an evt_<uuid> id in the webhook-id header AND inside data.id. Receivers MUST dedupe on event id — transient failures will replay the same event, and admins can manually replay any past delivery from developer-settings.

Sample envelope

JSON
{
  "type": "job.confirmed",
  "timestamp": "2026-05-26T10:23:14.012Z",
  "data": {
    "id": "evt_8c7b5d3a-2f4e-4d6a-9b1c-7e0a8d4f9c12",
    "jobId": "ckxyz...",
    "externalUserId": "your-internal-user-id",
    "externalOrderId": "your-order-id",
    "passthroughJson": { "your": "metadata" },
    "fulfillmentChannel": "MOBILE",
    "status": "CONFIRMED",
    "paymentStatus": "PAID"
  }
}

Event taxonomy

Twenty-seven event types, grouped by resource. The Channels column reflects which fulfillment channel a given event applies to — dispatch.assigned is MOBILE-only because venue Jobs are owned by venue staff.

Naming: Lola uses dot-case (job.created) vs Connecteam-style snake_case (job_created) — aligns with Stripe/GitHub conventions.

EventChannelsStatusSinceFires on
Job
job.createdBOTHLIVEv1Booking row written.
job.confirmedMOBILELIVEv1Phleb accepts. IN_VENUE jobs enter CONFIRMED at payment finalization (finalizePaidBooking), not at check-in.
job.in_progressBOTHLIVEv1Phleb starts (MOBILE) / venue check-in writes Job.startedAt (IN_VENUE — also fires checkedInAt).
job.completedBOTHLIVEv1Step-completion writes Job.completedAt.
job.cancelledBOTHLIVE (admin-side) / DEFERRED (phleb-side)v1Admin cancel; phleb-side cancel fires no webhook today — see Honest failure modes below.
job.rescheduledBOTHLIVEv1confirmedDateTime change (MOBILE) or venueSlotStart change (IN_VENUE).
job.en_routeMOBILELIVEv1Phleb marks en route; Job.enRouteAt written.
job.arrivedMOBILELIVEv1Phleb marks arrived; Job.arrivedAt written.
job.eta_updatedMOBILELIVEv1Phleb updates estimated arrival; payload carries previousEta + newEta.
job.no_showBOTHLIVEv1Patient flagged as a no-show.
Dispatch
dispatch.assignedMOBILE onlyLIVEv1Phleb accepts; payload carries the phleb name + phone.
dispatch.searchingMOBILE onlyLIVEv1Dispatch begins searching for a phleb; carries searchRadiusMiles.
dispatch.unassignedMOBILE onlyLIVEv1A previously-assigned phleb is removed; carries the reason.
Sample
sample.registeredBOTHLIVEv1Sample row created at completion; carries lab-handoff metadata.
sample.in_labBOTHLIVEv1Lab confirms receipt (Sample.status → RECEIVED_BY_LAB).
sample.results_readyBOTHLIVEv1Lab publishes results (Sample.status → RESULTS_READY); carries resultsUrl.
Invoice
invoice.createdBOTHLIVEv1Weekly invoice minted for a WEEKLY_INVOICE client.
invoice.paidBOTHLIVEv1Stripe confirms the invoice charge (invoice.paid webhook).
invoice.payment_failedBOTHLIVEv1A scheduled invoice charge fails.
invoice.overdueBOTHLIVEv1An unpaid invoice passes its due date.
Refund
refund.createdBOTHLIVEv1A refund is initiated against a Job.
refund.succeededBOTHLIVEv1Stripe confirms the refund settled.
refund.failedBOTHLIVEv1A refund attempt fails out-of-band.
Dispute
dispute.openedBOTHLIVEv1A chargeback is opened (charge.dispute.created); blocks refunds.
dispute.closedBOTHLIVEv1A chargeback is resolved (won or lost).
Venue
venue.activatedIN_VENUELIVEv1A venue is flipped to active (isActive false → true).
venue.deactivatedIN_VENUELIVEv1A venue is flipped to inactive; no new bookings accepted.

Honest failure modes

Things the surface does not do today. Treat each as a known gap rather than a bug — integrate accordingly.

  • No job.eta_updated event today.

    If you need ETA for your patient-facing UI, poll GET /api/integration/v1/bookings/:id — but note ETA fields are not yet projected in the GET response (see audit §G-C). Defer until V5.5.

  • No job.phleb_arrived event today.

    The schema has BookingStatus.ARRIVED + AdminNotificationType.PHLEB_ARRIVED but no Job.status transition writes it. Use the job.in_progress event as the arrival proxy.

  • No job.expired event for "no phleb available" scenarios today.

    The Job stays AVAILABLE indefinitely; ops is notified via Slack. Lola staff cancels manually; you receive job.cancelled then.

  • Phleb-side cancellations fire no webhook today.

    If a phleb cancels their accepted assignment, no client-side event fires. Admin must explicitly cancel for job.cancelled to reach you.

  • Reassignment re-fires dispatch.assigned.

    If a phleb declines and a different phleb accepts, you receive a SECOND dispatch.assigned event with the new phleb info. Treat as authoritative.

Next steps

Ready to build? The quickstart walks you through your first booking end-to-end, including the webhook events you should see fire along the way.