Booking Recommendations API
The Booking Recommendations API starts a read-only search for available delivery windows. It accepts a destination, fulfilling branch, delivery date, and cart, then returns an asynchronous poll handle.
Authentication
Send an account API key with HTTP Basic authentication:
Authorization: Basic <base64(externalUserId:apiKey)>
The fulfillingBranchId must be the API-key account or one of its descendant
accounts. Recommendations only consider routes in that branch's subtree, even
when a parent API key can access a larger account family. Sandbox API keys
produce sandbox-isolated recommendations.
Start a recommendation
POST /v1/booking/recommend
Content-Type: application/json
{
"cart": {
"items": [
{
"description": "Air conditioner",
"dimensionsCm": {
"height": 90,
"length": 120,
"width": 60
},
"quantity": 1,
"weightKg": 75
}
],
"orderValueCents": 12500
},
"contact": {
"email": "[email protected]",
"name": "Ada Buyer"
},
"date": "2026-08-15",
"deliveryMethod": "cargo-van",
"destination": {
"addressLine1": "123 Main St",
"city": "Los Angeles",
"postalCode": "90012",
"state": "CA"
},
"fulfillingBranchId": "acc_branch",
"origin": {
"addressLine1": "456 Branch Ave",
"city": "Oakland",
"postalCode": "94607",
"state": "CA"
},
"timeframeBucket": "FLEXIBLE"
}
Dimensions are centimeters, weight is kilograms, and order value is integer
cents. A single aggregate cart item is valid when SKU-level data is unavailable.
timeframeBucket is optional; accepted values are MORNING, AFTERNOON,
EVENING, AFTER_8PM, and FLEXIBLE. The account's slot policy controls which
buckets are available and supplies the default when the field is omitted.
origin is optional and overrides the pickup address. When it is omitted, the
account's configured pickup override is used if one is set; otherwise the
fulfilling branch's configured location is used. Coordinates may be supplied
with the override; otherwise the API geocodes the address.
A valid request returns 202:
{
"jobId": "recommendation-problem-id",
"problemId": "recommendation-problem-id",
"status": "processing"
}
Submitting identical result-affecting input while a search is in flight, or within 10 minutes of one that found windows, returns the handle for that existing search rather than starting a new one. Live and sandbox requests never share a handle.
There is one carve-out. If the previous search completed with an empty
windows array, or failed or was canceled, an identical retry re-runs the
search and returns a new handle. An empty result is only as fresh as the
route data it saw, so replaying it for the rest of the 10-minute window would
keep reporting "no availability" after the branch has planned the routes that
would satisfy the request.
problemId is the deterministic hash of your request and is stable for a given
input. jobId usually equals it, but a retry that re-runs one of the searches
above gets a distinct jobId. Poll the jobId you were handed; do not
recompute it or reuse one from an earlier response.
Poll for the result
Poll with the jobId returned above:
GET /v1/booking/jobs/{jobId}
While the recommendation is still computing, the API returns 202:
{
"status": "processing"
}
The response includes Retry-After: 2; wait at least that many seconds before
polling again.
When it completes, the API returns 200 with the available delivery windows:
{
"status": "complete",
"windows": [
{
"deliveryWindowStart": "2026-08-15T14:00:00.000Z",
"deliveryWindowEnd": "2026-08-15T15:00:00.000Z",
"price": {
"amountCents": 4200,
"currency": "usd",
"model": "markup",
"billedToAccount": false
}
},
{
"deliveryWindowStart": "2026-08-15T15:20:00.000Z",
"deliveryWindowEnd": "2026-08-15T16:00:00.000Z",
"price": {
"amountCents": 4200,
"currency": "usd",
"model": "markup",
"billedToAccount": false
}
}
]
}
Each window exposes the proposed delivery window and, when available, its
price.
Windows used to be returned best first, ranked by an internal score, and
spanned the full width of the requested timeframe bucket (an AFTERNOON
request returned a single two-hour block). Both are now different:
- Windows are hourly. A multi-hour timeframe bucket is searched as
successive one-hour sub-windows, so an
AFTERNOONrequest can return several windows rather than one wide one.deliveryWindowStartis the proposed service start and can fall inside the hour;deliveryWindowEndis the end of that hour. - Windows are returned in chronological order, earliest
deliveryWindowStartfirst. There is no longer a ranking to read off the list order — treat every returned window as equally bookable and present them in the order given. - More than one route may offer the same time. Windows that overlap or share a start time are distinct delivery options, so the list can contain near-duplicate times.
If you previously read windows[0] as "the best option", read it as "the
earliest option" instead.
The price is the customer-facing delivery fee, computed from the authenticated
account's effective booking settings. The nearest account in its hierarchy with
stored settings wins; when none exists, pricing defaults to pass_through.
amountCents— the fee in cents. It is0when a free-over-threshold (free_over) is met for the request's order value.currency— alwaysusd.model— the pricing model applied:markup,pass_through,free_over, oron_account.billedToAccount—trueonly foron_account: the fee is shown but billed to your account rather than charged to the buyer.
Because all returned windows are scheduled delivery, the price is the same
across every window in a response. If pricing cannot be computed, the price
field is omitted rather than failing the request.
If the search completes but no honorable scheduled window is available, the
response still completes successfully with an empty windows array and a
fallback signal indicating the caller should fall back to an on-demand
("rush") delivery:
{
"status": "complete",
"windows": [],
"fallback": "asap"
}
A jobId is only pollable by the account that created it, and results are
retained for 10 minutes after completion.
Errors
Invalid input returns 400 invalid_input. An unknown branch returns
400 branch_not_found; an out-of-scope branch returns
403 branch_out_of_scope. If no usable pickup origin can be resolved, the API
returns 400 origin_unresolved. If the destination postal code has no known
timezone, the API returns 400 timezone_not_found. Rate-limited requests
return 429 with both a Retry-After header and retryAfterSeconds in the
error body.
Slot-policy failures return 400 date_out_of_range, 400 date_blackout,
400 lead_time_not_met, 400 same_day_cutoff_passed, or
400 timeframe_bucket_not_available.
Transient server failures return 500 input_resolution_failed or
500 enqueue_failed; retrying the same request is safe. Retries are
deduplicated against an in-flight or recently successful search, subject to the
empty-result carve-out described above.
When polling, an unknown, expired, or out-of-scope jobId returns
404 job_not_found, and a recommendation that failed to produce a usable
result returns 500 optimization_failed (retrying is safe).