Book a consultation
POST /api/consultations: book a consultation into an attorney's free slot.
POST /api/consultationsBooks a consultation (scope consultations:write). The payload is validated, the requested time is
checked against the attorney's office hours and time off for that day, and the database guarantees
nobody else holds that time. The booked consultation is returned in full with status scheduled,
and a consultation.booked webhook fires. If the firm maps "consultation booked" (or, for a
booking sent with paid: true, "consultation paid") to a pipeline stage, the linked lead also moves to it and a
lead.status_changed webhook fires. It doesn't move (and no webhook fires) when the lead is archived, in a final
stage or already in that stage, or when the stage is retired.
Request body
JSON. lead_id, attorney_id, type, start_at, and duration_min are required. The API books a
real slot, so both the lead and the attorney are mandatory: an attorney is needed to validate office
hours.
| Field | Type | Notes |
|---|---|---|
lead_id | string | Required. The lead (UUID) this consultation is for. |
attorney_id | string | Required. A schedulable, active attorney (UUID) in your firm. |
type | string | Required. The consultation type (free text, e.g. Initial consultation). |
start_at | string | Required. ISO 8601 start instant, interpreted as UTC. |
duration_min | number | Required. Length in minutes, 5 to 1440. |
time_zone | string | Optional IANA zone the consultation is shown in. Defaults to America/New_York. |
paid | boolean | Optional: book it as already paid (for example, paid outside the app). Card payments are taken in the app. Defaults to false. |
amount | number | null | Optional fee in dollars; nonnegative, or null. 0 books a free consultation, which holds its time. Left out or null, no fee is recorded and the booking is unpaid: it shares its time until it's paid. |
data | object | Optional practice specific fields. |
start_at is a UTC instant, not a wall clock time: the API is machine to machine, so there's no
time zone conversion on the way in. time_zone only sets how the consultation is displayed in
the app. Validation of office hours and time off compares the resulting local time against the
attorney's schedule.
Unpaid bookings share a time
A consultation holds its time on the attorney's calendar once it's paid, or when its fee is zero (amount of
0). An unpaid booking doesn't, and that includes one sent without an amount (or with null): no fee is recorded,
so it's unpaid. Several unpaid bookings can share a time (the office books them in pencil), and the first one paid
takes it. Only consultations that end after now count: an overlap in the past is history. So:
- Booking an unpaid consultation into a time other unpaid bookings already share succeeds.
- Booking anything into a time a paid or free consultation already holds is a
409 slot_unavailable, and so is a booking sent withpaid: true(oramount: 0) over another booking that holds the time, or over one being paid for by a live card right now (a test charge never blocks it). - When a booking starts holding a time over unpaid ones, those stay booked and are flagged "Needs a new time" in the app for staff to move; they're never canceled automatically.
Idempotency
Send an Idempotency-Key header to make a booking safe to retry: a repeat with the same key
returns the original consultation instead of booking a second. Use a fresh unique value (e.g. a UUID)
per logical booking.
Idempotency-Key: 9d2b7c1e-5a4b-4c3d-8e2f-1a2b3c4d5e6fExample
curl https://app.lawfficient.com/api/consultations \
-H "Authorization: Bearer $LAWFFICIENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9d2b7c1e-5a4b-4c3d-8e2f-1a2b3c4d5e6f" \
-d '{
"lead_id": "3f8c1e2a-1b2c-4d5e-8f90-abcdef012345",
"attorney_id": "a1b2c3d4-0000-4000-8000-000000000000",
"type": "Initial consultation",
"start_at": "2026-07-02T15:00:00.000Z",
"duration_min": 30,
"time_zone": "America/New_York"
}'{
"id": "7c1e2a3f-1b2c-4d5e-8f90-abcdef012345",
"lead_id": "3f8c1e2a-1b2c-4d5e-8f90-abcdef012345",
"attorney_id": "a1b2c3d4-0000-4000-8000-000000000000",
"type": "Initial consultation",
"status": "scheduled",
"start_at": "2026-07-02T15:00:00.000Z",
"duration_min": 30,
"time_zone": "America/New_York",
"paid": false,
"amount": 150,
"outcome": null,
"archived": false,
"created_at": "2026-06-28T10:00:00.000Z",
"data": {}
}Errors
Errors use the error envelope:
code | Status | When |
|---|---|---|
invalid_request | 422 | A missing/invalid field, a lead_id/attorney_id that isn't a UUID, or a lead/attorney that isn't a bookable member of your firm. Also a lead that is missing a contact field your firm requires at booking (the message names the field's key); fill it in on the lead (in the app, or through data with Update a lead), then book again. |
outside_office_hours | 422 | The time falls outside the attorney's office hours. |
attorney_unavailable | 422 | The attorney has time off (or a holiday) on that date. |
slot_unavailable | 409 | Another consultation already holds that time on the attorney's calendar (it's paid, or its amount is 0), or, for a paid or free booking, another consultation at that time is being paid for by a live card right now (test charges never block it). The message says which. |
insufficient_scope | 403 | The key lacks consultations:write. |
Rejections for office hours or time off are a 422 (the request is well formed but the slot isn't
bookable), while a time another consultation holds is a 409. Branch on the code, not the status, to tell a
slot outside office hours from a held time.