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.
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.
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.
# 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 \
| base64Minimal verification + payload
The shortest verification path: hand the raw body and headers to the standardwebhooks library, read event.data.
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 belowA representative event.data for a freshly-created WEEKLY_INVOICE booking:
{
"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
{
"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.
| Event | Channels | Status | Since | Fires on |
|---|---|---|---|---|
| Job | ||||
job.created | BOTH | LIVE | v1 | Booking row written. |
job.confirmed | MOBILE | LIVE | v1 | Phleb accepts. IN_VENUE jobs enter CONFIRMED at payment finalization (finalizePaidBooking), not at check-in. |
job.in_progress | BOTH | LIVE | v1 | Phleb starts (MOBILE) / venue check-in writes Job.startedAt (IN_VENUE — also fires checkedInAt). |
job.completed | BOTH | LIVE | v1 | Step-completion writes Job.completedAt. |
job.cancelled | BOTH | LIVE (admin-side) / DEFERRED (phleb-side) | v1 | Admin cancel; phleb-side cancel fires no webhook today — see Honest failure modes below. |
job.rescheduled | BOTH | LIVE | v1 | confirmedDateTime change (MOBILE) or venueSlotStart change (IN_VENUE). |
job.en_route | MOBILE | LIVE | v1 | Phleb marks en route; Job.enRouteAt written. |
job.arrived | MOBILE | LIVE | v1 | Phleb marks arrived; Job.arrivedAt written. |
job.eta_updated | MOBILE | LIVE | v1 | Phleb updates estimated arrival; payload carries previousEta + newEta. |
job.no_show | BOTH | LIVE | v1 | Patient flagged as a no-show. |
| Dispatch | ||||
dispatch.assigned | MOBILE only | LIVE | v1 | Phleb accepts; payload carries the phleb name + phone. |
dispatch.searching | MOBILE only | LIVE | v1 | Dispatch begins searching for a phleb; carries searchRadiusMiles. |
dispatch.unassigned | MOBILE only | LIVE | v1 | A previously-assigned phleb is removed; carries the reason. |
| Sample | ||||
sample.registered | BOTH | LIVE | v1 | Sample row created at completion; carries lab-handoff metadata. |
sample.in_lab | BOTH | LIVE | v1 | Lab confirms receipt (Sample.status → RECEIVED_BY_LAB). |
sample.results_ready | BOTH | LIVE | v1 | Lab publishes results (Sample.status → RESULTS_READY); carries resultsUrl. |
| Invoice | ||||
invoice.created | BOTH | LIVE | v1 | Weekly invoice minted for a WEEKLY_INVOICE client. |
invoice.paid | BOTH | LIVE | v1 | Stripe confirms the invoice charge (invoice.paid webhook). |
invoice.payment_failed | BOTH | LIVE | v1 | A scheduled invoice charge fails. |
invoice.overdue | BOTH | LIVE | v1 | An unpaid invoice passes its due date. |
| Refund | ||||
refund.created | BOTH | LIVE | v1 | A refund is initiated against a Job. |
refund.succeeded | BOTH | LIVE | v1 | Stripe confirms the refund settled. |
refund.failed | BOTH | LIVE | v1 | A refund attempt fails out-of-band. |
| Dispute | ||||
dispute.opened | BOTH | LIVE | v1 | A chargeback is opened (charge.dispute.created); blocks refunds. |
dispute.closed | BOTH | LIVE | v1 | A chargeback is resolved (won or lost). |
| Venue | ||||
venue.activated | IN_VENUE | LIVE | v1 | A venue is flipped to active (isActive false → true). |
venue.deactivated | IN_VENUE | LIVE | v1 | A 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.