How does mobile fulfillment work?
You submit a MOBILE booking; we route it to the right phlebotomist based on coverage and availability. You don't pick the phleb — we do.
You learn the assigned phleb's name + phone via the dispatch.assigned webhook. Everything else (the phleb's app, our dispatch logic, in-app chat) is invisible to you by design — we own the workforce.
Your job lifecycle is the 8 webhook events + the GET /bookings/:id polling endpoint. If you need to surface status to a patient, that's what to wire.
Requesting scopes
Every API key is minted with an explicit set of scopes. Request only what you need in your access email — Lola staff set them at mint time. There are six:
- bookings:read
- List and fetch your bookings (GET /bookings, GET /bookings/:id) and their sample state.
- bookings:write
- Create bookings, mint hosted-booking URLs, and cancel bookings (POST /bookings, POST /booking-url, POST /bookings/:id/cancel).
- venues:read
- Discover the venue network and bookable slots (GET /venues, /venues/:id, /venues/:id/availability, /venue-field-definitions).
- invoices:read
- Read weekly invoices and their line items for reconciliation.
- refunds:read
- Read refund records issued against your bookings.
- disputes:read
- Read chargeback / dispute records raised against your bookings.
Mobile booking — end to end
The default channel. Seven steps from access request to results-ready, each one optional after step 3 — you can build a minimum integration on steps 1–4 alone.
- Step 1
Get API access
Email developers@loladispatch.com with your company, use case, and expected volume. Lola staff issue API keys after a short intake — no self-serve signup today.
- Step 2
Get your
pj_live_…keyOnce your client org has the API tier enabled, mint a key from clients.loladispatch.com/developer-settings. The plaintext value is shown once; the server stores only a SHA-256 hash.
- Step 3
Create a MOBILE booking (
WEEKLY_INVOICEbilling)One POST. The response carries the new Job's id; you'll receive the signed
job.createdwebhook in milliseconds.curlcurl -X POST https://app.loladispatch.com/api/integration/v1/bookings \ -H "X-API-Key: $LOLA_API_KEY" \ -H "X-API-Version: 1" \ -H "Content-Type: application/json" \ -d '{ "fulfillmentChannel": "MOBILE", "billingMode": "WEEKLY_INVOICE", "patient": { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "phone": "+447911123456", "address": "12 Example St, London, SW1A 1AA" }, "scheduledFor": "2026-06-01T09:00:00Z", "testPanelIds": ["panel_core_health"], "externalUserId": "your-internal-user-id", "externalOrderId": "your-order-id" }'Nodeimport { fetch } from 'undici'; const res = await fetch('https://app.loladispatch.com/api/integration/v1/bookings', { method: 'POST', headers: { 'X-API-Key': process.env.LOLA_API_KEY!, 'X-API-Version': '1', 'Content-Type': 'application/json', }, body: JSON.stringify({ fulfillmentChannel: 'MOBILE', billingMode: 'WEEKLY_INVOICE', patient: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', phone: '+447911123456', address: '12 Example St, London, SW1A 1AA', }, scheduledFor: '2026-06-01T09:00:00Z', testPanelIds: ['panel_core_health'], externalUserId: 'your-internal-user-id', externalOrderId: 'your-order-id', }), }); const job = await res.json(); console.log(job.id, job.status); // CONFIRMED, paymentStatus PENDINGPythonimport os import requests res = requests.post( "https://app.loladispatch.com/api/integration/v1/bookings", headers={ "X-API-Key": os.environ["LOLA_API_KEY"], "X-API-Version": "1", "Content-Type": "application/json", }, json={ "fulfillmentChannel": "MOBILE", "billingMode": "WEEKLY_INVOICE", "patient": { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "phone": "+447911123456", "address": "12 Example St, London, SW1A 1AA", }, "scheduledFor": "2026-06-01T09:00:00Z", "testPanelIds": ["panel_core_health"], "externalUserId": "your-internal-user-id", "externalOrderId": "your-order-id", }, timeout=10, ) res.raise_for_status() job = res.json() print(job["id"], job["status"]) # CONFIRMED, paymentStatus PENDINGRequest body
JSON{ "fulfillmentChannel": "MOBILE", "patient": { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "phone": "+447911123456", "dateOfBirth": "1990-04-12", "gender": "FEMALE" }, "location": { "addressLine1": "12 Example Street", "city": "London", "postcode": "SW1A 1AA" }, "appointment": { "preferredDate": "2026-06-01", "timePreference": "morning", "testType": "panel_core_health" }, "externalUserId": "your-internal-user-id", "externalOrderId": "your-order-id" }Response (WEEKLY_INVOICE)
PATIENT_PAY clients instead receive
status: "PENDING_PAYMENT"and apaymentRequiredUrlthe patient must open.JSON{ "jobId": "ckxyz123", "status": "PENDING", "bookingUrl": "https://admin.loladispatch.com/jobs/ckxyz123" } - Step 4
Inspect the
job.createdenvelopeThe first webhook lands on your endpoint. Your
externalUserIdandexternalOrderIdare echoed back on every event for the life of the Job — use them as your correlation keys, not Lola's internaljobId.JSON{ "type": "job.created", "timestamp": "2026-06-01T08:14:02.123Z", "data": { "id": "evt_8c7b5d3a-2f4e-4d6a-9b1c-7e0a8d4f9c12", "jobId": "ckxyz...", "externalUserId": "your-internal-user-id", "externalOrderId": "your-order-id", "passthroughJson": null, "fulfillmentChannel": "MOBILE", "status": "CONFIRMED", "paymentStatus": "PENDING", "billingMode": "WEEKLY_INVOICE", "scheduledFor": "2026-06-01T09:00:00Z", "patient": { "firstName": "Jane", "lastName": "D.", "phoneLast4": "0000" } } }See Webhooks for signature verification + dedupe rules.
- Step 5
Receive
dispatch.assignedwhen a phleb acceptsWhen a phlebotomist accepts the assignment, you receive
dispatch.assignedwith the phleb's name and phone number. This is the only event you need to surface the "who is coming" to your patient — Lola owns the rest of the dispatch UI. - Step 6
Receive
job.in_progresswhen the phleb startsUse this as your "phleb arrived" proxy — see the honest-failure callouts for why there's no dedicated arrival event today.
- Step 7
Receive
job.completed+sample.registeredWhen the phleb completes the visit and the sample is registered, both events fire.
sample.registeredcarries the lab handoff metadata (shipmentMode,shipmentModeSource,clientMetadata,collectionNotes).
In-venue quick start
The sibling channel. The patient comes to a venue at a booked slot; venue staff check them in and collect the sample. Same booking endpoint, same webhook envelope — different inputs.
- Step 1
List venues
Filter by postcode + radius. Returns the venue id, name, address, hours, and whether it accepts public bookings.
curlcurl -X GET 'https://app.loladispatch.com/api/integration/v1/venues?postcode=SW1A&radiusKm=10' \ -H "X-API-Key: $LOLA_API_KEY" \ -H "X-API-Version: 1" - Step 2
Check slot availability
Discrete slot starts within a date range — already capacity-checked against existing bookings. The slot start you pick is the one you POST in step 3.
curlcurl -X GET 'https://app.loladispatch.com/api/integration/v1/venues/venue_abc/availability?from=2026-06-01&to=2026-06-07' \ -H "X-API-Key: $LOLA_API_KEY" \ -H "X-API-Version: 1" - Step 3
Create an IN_VENUE booking
Same endpoint as MOBILE; flip
fulfillmentChanneltoIN_VENUEand passvenueId+venueSlotStartinstead ofaddress+scheduledFor.curlcurl -X POST https://app.loladispatch.com/api/integration/v1/bookings \ -H "X-API-Key: $LOLA_API_KEY" \ -H "X-API-Version: 1" \ -H "Content-Type: application/json" \ -d '{ "fulfillmentChannel": "IN_VENUE", "billingMode": "WEEKLY_INVOICE", "venueId": "venue_abc", "venueSlotStart": "2026-06-02T10:30:00Z", "patient": { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "phone": "+447911123456" }, "testPanelIds": ["panel_core_health"], "externalUserId": "your-internal-user-id", "externalOrderId": "your-order-id" }'Nodeimport { fetch } from 'undici'; const res = await fetch('https://app.loladispatch.com/api/integration/v1/bookings', { method: 'POST', headers: { 'X-API-Key': process.env.LOLA_API_KEY!, 'X-API-Version': '1', 'Content-Type': 'application/json', }, body: JSON.stringify({ fulfillmentChannel: 'IN_VENUE', billingMode: 'WEEKLY_INVOICE', venueId: 'venue_abc', venueSlotStart: '2026-06-02T10:30:00Z', patient: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', phone: '+447911123456', }, testPanelIds: ['panel_core_health'], externalUserId: 'your-internal-user-id', externalOrderId: 'your-order-id', }), }); const job = await res.json(); console.log(job.id, job.status); // CONFIRMED, paymentStatus PENDINGPythonimport os import requests res = requests.post( "https://app.loladispatch.com/api/integration/v1/bookings", headers={ "X-API-Key": os.environ["LOLA_API_KEY"], "X-API-Version": "1", "Content-Type": "application/json", }, json={ "fulfillmentChannel": "IN_VENUE", "billingMode": "WEEKLY_INVOICE", "venueId": "venue_abc", "venueSlotStart": "2026-06-02T10:30:00Z", "patient": { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "phone": "+447911123456", }, "testPanelIds": ["panel_core_health"], "externalUserId": "your-internal-user-id", "externalOrderId": "your-order-id", }, timeout=10, ) res.raise_for_status() job = res.json() print(job["id"], job["status"]) # CONFIRMED, paymentStatus PENDING - Step 4
(Optional) Read
venueCompletionFieldsEach venue can require additional fields at sample collection (e.g. fasting confirmation, allergy flags). Fetch the definitions ahead of time if you want to surface them in your own UI:
curlcurl -X GET https://app.loladispatch.com/api/integration/v1/venue-field-definitions \ -H "X-API-Key: $LOLA_API_KEY" \ -H "X-API-Version: 1" - Step 5
Receive
job.in_progresswithcheckedInAtWhen venue staff check the patient in, you receive
job.in_progresswith acheckedInAttimestamp on the data envelope. Nodispatch.assignedevent fires for IN_VENUE — venue staff own the Job, no phleb is dispatched. - Step 6
Receive
sample.registeredCarries
clientMetadata(the values venue staff entered for the venue's custom fields),collectionNotes,shipmentMode, andshipmentModeSource. Treat this as the "ready for lab" signal.
Event taxonomy at a glance
For the full taxonomy, fire-conditions, and reserved-for-LV1 events, see Webhooks. Repeated here as a quick reference while you build.
| 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. |
Ready to start?
The API reference has every endpoint, every parameter, and a try-it panel that uses your real API key. Webhooks has the verification deep-dive.