bidrayl API
Quote intermodal freight, manage customers, and pull documents from your own systems. Everything the dashboard does, it does through this API.
The API is REST over HTTPS, JSON in and JSON out, and versioned in the path.
Money is always integer cents. Dates are always YYYY-MM-DD.
Base URL
https://api.bidrayl.com/api/v1
Every path in this reference is relative to that base unless it starts with a
slash and is called out as unversioned (/health).
Getting started
Three steps: create a key, quote a lane, read the price.
1. Create an API key. In the dashboard, go to Settings → API keys and create a private key. The secret is shown once, at creation. Store it somewhere your server can read it; we keep only a hash and cannot recover it.
2. Quote a lane.
curl -X POST https://api.bidrayl.com/api/v1/quotes/ramp-to-ramp \
-H "X-API-Key: $BIDRAYL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin_zip": "60601",
"destination_zip": "90001",
"equipment_code": "53FT",
"ship_date": "2026-10-15"
}'
3. Read the answer. You get back one or more routes, cheapest first, each
broken into legs — dray to the origin ramp, rail between ramps, dray to the
door — with linehaul and fuel surcharge separated on every leg.
{
"quote_id": 4812,
"quote_number": "R2R-2026-000512",
"quote_history_id": 9931,
"per_diem_daily_rate_cents": 10000,
"per_diem_daily_rate_dollars": "$100.00",
"fuel_surcharge": { "pct": 0.32, "pct_display": "32%", "effective_from": "2026-10-06" },
"generated_at": "2026-10-09T14:22:05Z",
"routes": [
{
"total_cost_cents": 357500,
"total_cost_dollars": "$3575.00",
"total_transit_days": 6,
"legs": [
{
"sequence": 1,
"type": "DRAY",
"provider": { "id": 42, "name": "Dray Carrier A", "type": "DRAY" },
"origin": { "type": "ADDRESS", "zip": "60601" },
"destination": { "type": "RAMP", "ramp_id": 14, "ramp_code": "CHI_RAMP_A", "ramp_name": "Chicago Intermodal Terminal" },
"base_cost_cents": 42500,
"base_cost_dollars": "$425.00",
"fuel_surcharge_cents": 13600,
"fuel_surcharge_pct": 0.32,
"cost_cents": 56100,
"cost_dollars": "$561.00",
"transit_days": 1,
"distance_miles": 31.4,
"is_estimate": false
}
]
}
]
}
That is the whole loop. The rest of this document is detail.
Authentication
Two credential types reach the same endpoints. Pick by caller.
| Credential | Header | Use it for |
|---|---|---|
| API key | X-API-Key: dk_… |
Servers, TMS integrations, scripts, agents |
| JWT | Authorization: Bearer <access_token> |
Interactive sessions (this is what the dashboard uses) |
API keys
Keys come in two types, and the difference matters:
- Private (
dk_prefix) — full access to every endpoint in this reference. Server-side only. Never ship one to a browser or a mobile app. - Public (
pk_prefix) — accepted for authentication but rejected by every endpoint except the widget. Safe to embed in a page, because the only thing it can do is submit a quote request.
Manage them under /api-keys:
# Create a key — the secret is in this response and nowhere else, ever
curl -X POST https://api.bidrayl.com/api/v1/api-keys \
-H "X-API-Key: $BIDRAYL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "TMS integration", "key_type": "private"}'
{
"id": 7,
"name": "TMS integration",
"key_prefix": "dk_8fK2p",
"key_type": "private",
"secret": "dk_8fK2p…",
"created_at": "2026-10-09T14:22:05Z"
}
GET /api-keys returns a JSON array of keys without secrets (id, name,
key_prefix, key_type, active, last_used_at, created_at).
DELETE /api-keys/{id} revokes one — 204, and the key stops working
immediately.
An API key belongs to your tenant rather than to a person. It carries the
member role, so it can quote, read and write CRM records, and generate
documents, but it cannot manage users. Records it creates report
created_by: null alongside created_by_api_key_name, so an integration's
writes stay distinguishable from a person's.
JWT sessions
curl -X POST https://api.bidrayl.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "dana@acme-logistics.com", "password": "…"}'
{ "access_token": "…", "refresh_token": "…", "expires_in": 3600, "token_type": "Bearer" }
Access tokens are short-lived. On 401 unauthorized, POST the refresh token to
/auth/refresh for a new pair and retry the request once. POST /auth/logout
revokes the refresh token; GET /auth/me returns the current user and tenant.
Registration (POST /auth/register) creates an account in the pending
state and issues no tokens — a new tenant cannot log in until we approve it.
Login and refresh return 403 with code account_pending or
account_rejected until then.
Conventions
Errors
Every error is JSON with the same shape:
{ "code": "validation_error", "message": "Validation failed", "status": 400 }
Validation failures add a field-level breakdown, which is what you want to show a user:
{
"code": "validation_error",
"message": "Validation failed",
"status": 400,
"errors": [
{ "field": "equipment_code", "message": "must be one of: 20FT, 40FT, 40HC, 45FT, 53FT" },
{ "field": "ship_date", "message": "required" }
]
}
Handle code, not message — messages are written for humans and may be
reworded. See the error reference for the full list.
Money
Every amount is an integer of cents in a *_cents field, with a pre-formatted
display string in the matching *_dollars field. Read *_cents for
arithmetic; *_dollars exists so a UI does not have to reimplement currency
formatting.
Dates and times
Service dates (ship_date, pickup_date, due_date) are YYYY-MM-DD.
Timestamps (created_at, generated_at) are RFC 3339 UTC. Task due_time is
HH:MM.
Missing versus zero
A field we do not know is omitted, never zeroed. This matters most for
distance_miles: a formula-priced dray leg or a ramp pair with no computed
rail mileage has no distance_miles at all. Treat its absence as unknown, not
as a zero-mile move.
Pagination
List endpoints take ?limit=&offset=. limit defaults to 20 on quote and
activity lists and 50 on CRM record lists, and caps at 100 — a larger value
falls back to the default rather than erroring. Responses carry a total (the
unpaginated count), either at the top level or inside a pagination block:
{ "companies": [ … ], "total": 214, "limit": 50, "offset": 0 }
{ "items": [ … ], "pagination": { "limit": 20, "offset": 0, "total": 96 } }
Use total for "load more" controls rather than probing for a short page.
Request IDs
Every response carries X-Request-ID. Send your own to correlate with your
logs, or read ours back. Quote it in support requests — it is how we find the
exact call.
Content types
Send Content-Type: application/json on request bodies. Three groups of
endpoints differ: file uploads are multipart/form-data, PDF generation
returns application/pdf by default, and the Excel endpoints return a
spreadsheet. Each is called out where it appears.
Plans and entitlements
Quoting endpoints are gated by your subscription. Everything else — CRM, quote history, documents, locations — is available on every plan.
| Capability | Endpoints | Plan |
|---|---|---|
d2d |
/rates/d2d/* |
Starter and up |
r2r |
/quotes/ramp-to-ramp (single), /quotes/combined |
Pro and up |
r2r_bulk |
/quotes/ramp-to-ramp/bulk* |
Business |
A call outside your plan returns 403 with code feature_not_entitled. Do not
retry it — it will fail identically until the plan changes.
Read your own entitlements rather than hardcoding plan rules:
curl https://api.bidrayl.com/api/v1/me/entitlements \
-H "X-API-Key: $BIDRAYL_API_KEY"
{
"plan": "pro",
"plan_name": "Pro",
"subscription_status": "active",
"access_active": true,
"capabilities": ["d2d", "r2r"],
"seats_used": 3,
"seats_total": 5
}
GET /plans is public and returns the catalog with prices, for a pricing page.
Quoting
Three modes, one idea: you describe a lane, we price it.
| Mode | Endpoint | What it is | Speed |
|---|---|---|---|
| Door-to-door | POST /rates/d2d |
What marketplace carriers quote for the whole move, one price each | Bounded by the carriers' own APIs |
| Ramp-to-ramp | POST /quotes/ramp-to-ramp |
Our own build-up: dray + rail + dray, costed leg by leg | Fast — priced here, from rate sheets we hold |
| Combined | POST /quotes/combined |
Both, in parallel, for comparison | As slow as its D2D half |
R2R is the mode to reach for when latency matters: it is computed on our side and returns far faster than a carrier round-trip. See practical notes.
Door-to-door (D2D)
One request fans out to every carrier you have enabled and returns a result per carrier.
curl -X POST https://api.bidrayl.com/api/v1/rates/d2d \
-H "X-API-Key: $BIDRAYL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_ref": "PO-4471",
"origin_zip": "60601",
"destination_zip": "90001",
"pickup_date": "2026-10-15",
"equipment_type": "53DRY"
}'
201 Created:
{
"id": 3312,
"customer_ref": "PO-4471",
"origin_zip": "60601",
"origin_city": "Chicago",
"destination_zip": "90001",
"destination_city": "Los Angeles",
"pickup_date": "2026-10-15",
"equipment_type": "53DRY",
"status": "completed",
"created_at": "2026-10-09T14:22:05Z",
"results": [
{
"id": 11204,
"carrier_code": "carrier_a",
"carrier_name": "Carrier A Intermodal",
"carrier_quote_id": "TO-99812",
"mode": "INTERMODAL",
"price_cents": 341200,
"price_dollars": "$3412.00",
"transit_days": 6,
"status": "success",
"contact": { "website": "https://…", "email": "quotes@…" }
},
{
"id": 11205,
"carrier_code": "carrier_b",
"carrier_name": "Carrier B Logistics",
"status": "no_service"
}
]
}
Fields
| Field | Required | Notes |
|---|---|---|
origin_zip, destination_zip |
yes | 5-digit US ZIP |
pickup_date |
yes | YYYY-MM-DD |
equipment_type |
yes | 53DRY — 53' dry container is the only equipment marketplace D2D quotes today |
customer_ref |
no | Your own reference, ≤ 100 chars. Look the quote up later with GET /rates/d2d/by-ref/{ref} |
Carrier codes and names throughout this reference (carrier_a,
Carrier A Intermodal, Dray Carrier A) are placeholders. The codes your
account can actually query come from GET /rates/d2d/providers; read them at
runtime rather than hardcoding one.
Each result's status is success, no_service (that carrier does not serve
the lane) or error (the carrier's system failed, with error_message). A
mixed response is normal and is not a failure — read per result.
carrier_quote_id is the carrier's own reference for the price, worth storing
if you will book against it.
Other D2D endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/rates/d2d?limit=&offset= |
List past requests (summaries, with lowest_price_cents) |
GET |
/rates/d2d/{id} |
One request with all carrier results |
GET |
/rates/d2d/by-ref/{ref} |
Look up by your customer_ref |
DELETE |
/rates/d2d/{id} |
Delete a request (204) |
GET |
/rates/d2d/providers |
Carriers available to you, with contact details |
POST |
/rates/d2d/providers/{code} |
Quote one carrier |
Quoting carriers one at a time
The batch call waits for every carrier, so it is as slow as the slowest one. If you are rendering a UI, drive it per carrier instead: list the providers, then fire one request each and fill in cards as they land.
curl -X POST https://api.bidrayl.com/api/v1/rates/d2d/providers/carrier_a \
-H "X-API-Key: $BIDRAYL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"origin_zip":"60601","destination_zip":"90001","pickup_date":"2026-10-15","equipment_type":"53DRY"}'
The body is identical to the batch request; the response is a single result
object. An unknown or disabled carrier code is 404 carrier_not_found. If no
carriers are available at all, the providers list returns
503 no_carriers_available.
Ramp-to-ramp (R2R)
This is our own routing and pricing rather than a carrier's answer: we find the ramps, price the dray on both ends against real rate sheets, price the rail between them, and hand back the whole build-up.
curl -X POST https://api.bidrayl.com/api/v1/quotes/ramp-to-ramp \
-H "X-API-Key: $BIDRAYL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin_zip": "60601",
"destination_zip": "90001",
"equipment_code": "53FT",
"ship_date": "2026-10-15",
"optimize": "cost",
"max_results": 3
}'
Fields
| Field | Required | Notes |
|---|---|---|
origin_zip, destination_zip |
yes | 5-digit US ZIP |
equipment_code |
yes | 20FT, 40FT, 40HC, 45FT, 53FT |
ship_date |
yes | YYYY-MM-DD |
origin_address, destination_address |
no | Street address, for a more exact dray price |
include_origin_dray, include_destination_dray |
no | Default true. Set false for a genuinely ramp-to-ramp move where the customer handles that end |
optimize |
no | cost (default) or speed |
max_results |
no | 1–50 routes |
provider_ids |
no | Restrict to specific rail providers |
hazmat |
no | Adds a flat $450 charge, once per route |
origin_bobtail, destination_bobtail |
no | Doubles that end's dray miles and price |
Reading the response
quote_number (R2R-YYYY-NNNNNN) is the customer-facing identifier.
quote_id addresses the quote on the edit and PDF endpoints. quote_history_id
is the mode-agnostic handle you use to file the quote against a customer.
Each route is an ordered list of legs:
Leg type |
What it is |
|---|---|
DRAY |
Truck between a door and a ramp |
RAIL |
Rail between two ramps |
CROSSTOWN |
Truck between two ramps, for an interline route |
PER_DIEM |
Container day charge — a cost, not a movement |
HAZMAT |
The hazmat charge, spanning the whole move |
Every leg separates base_cost_cents (linehaul) from fuel_surcharge_cents,
with cost_cents as their sum. is_estimate: true marks a formula-priced leg
rather than one off a rate sheet. The top-level fuel_surcharge block reports
the EIA-derived percentage applied and the week it took effect.
transit_days on a RAIL leg is our estimate derived from rail mileage —
no rail rate sheet we import publishes a service time. It is good for comparing
routes and is not a service guarantee.
A bobtail leg doubles mileage and price, and the fuel surcharge with them, but deliberately does not change transit days: the shipment still travels the lane once.
Choosing a different dray carrier. A DRAY leg may carry
alternatives[] — the other carriers serving that same leg, cheapest first,
each fully priced. The leg's own provider is the cheapest of them.
Editing a stored quote
Both edits recompute route totals, persist, and return the full updated quote, so a PDF generated afterwards reflects the change.
# Set the per-diem day count on a leg
curl -X PATCH https://api.bidrayl.com/api/v1/quotes/ramp-to-ramp/4812/per-diem \
-H "X-API-Key: $BIDRAYL_API_KEY" -H "Content-Type: application/json" \
-d '{"updates":[{"route_index":0,"leg_sequence":4,"days":3}]}'
# Promote one of a dray leg's alternatives to be the primary carrier
curl -X PATCH https://api.bidrayl.com/api/v1/quotes/ramp-to-ramp/4812/dray \
-H "X-API-Key: $BIDRAYL_API_KEY" -H "Content-Type: application/json" \
-d '{"updates":[{"route_index":0,"leg_sequence":1,"provider_id":57}]}'
Days are clamped to 0–100. A provider_id that is not in that leg's
alternatives[], or a leg that is not DRAY, is a 400. A quote belonging to
another tenant is a 404.
Bulk lanes (RFPs)
For a bid sheet rather than a single lane. Business plan.
curl -X POST https://api.bidrayl.com/api/v1/quotes/ramp-to-ramp/bulk \
-H "X-API-Key: $BIDRAYL_API_KEY" -H "Content-Type: application/json" \
-d '{
"equipment_code": "53FT",
"margin": { "min_margin_cents": 15000, "margin_pct": 12, "max_margin_cents": 60000 },
"rows": [
{ "origin_zip": "60601", "destination_zip": "90001", "ship_date": "2026-10-15" },
{ "origin_city": "Savannah", "origin_state": "GA", "destination_city": "Dallas", "destination_state": "TX" }
]
}'
Each row takes a ZIP or a city+state pair per end. margin is optional and
overrides your saved default for this request only (margin_pct is a
whole-number percent). Each result carries up to three options — primary plus
two backups, cheapest first — and every option has:
- a flattened
summary: origin/destination ramp, equipment, the railroad whose sheet priced the rail leg, per-leg linehaul and fuel surcharge, per diem, and mileages; - the full
route, for expandable detail; cost_*,margin_*andall_in_*amounts, plustransit_days.
The response also reports total_rows, success_rows and failed_rows; a row
that could not be priced carries an error string instead of options.
| Method | Path | Purpose |
|---|---|---|
GET |
/quotes/ramp-to-ramp/bulk/template |
The canonical .xlsx upload template, with example rows |
POST |
/quotes/ramp-to-ramp/bulk |
JSON in, JSON out (above) |
POST |
/quotes/ramp-to-ramp/bulk/excel |
Spreadsheet in, wide colour-coded spreadsheet out |
GET |
/quotes/ramp-to-ramp/bulk/fuel-program-template |
Blank customer fuel-program workbook |
The Excel endpoint is multipart/form-data: file is the lane sheet, column
headers matched by name with extras ignored. equipment_code comes from a
query parameter or an Equipment column, and min_margin_cents /
margin_pct / max_margin_cents form fields override your saved margin.
An optional second file field, fuel_program, carries the customer's own
per-mile fuel program — which is not the carrier fuel surcharge. Supply it and
each option gains Fuel Comp and Cost less Fuel Comp columns, looked up once
per batch against the current weekly DOE national diesel average so every lane
is priced off the same figure. Omit it and those columns are left off entirely.
A fuel_program file we cannot read is a 400 rather than a silent skip.
Cells are left blank when a leg cannot say how far it went or diesel falls
outside the bands the program covers — in both cases we do not know what is
owed, and a zero would read as "this lane earns nothing". A band whose rate is
genuinely zero reports as $0.00.
Combined
Runs D2D and R2R in parallel for the same lane, using D2D-style equipment codes, so you can put a marketplace price next to your own build-up.
curl -X POST https://api.bidrayl.com/api/v1/quotes/combined \
-H "X-API-Key: $BIDRAYL_API_KEY" -H "Content-Type: application/json" \
-d '{"origin_zip":"60601","destination_zip":"90001","equipment_type":"53DRY","ship_date":"2026-10-15"}'
The response has a d2d block and an r2r block; either may be null with an
accompanying d2d_error / r2r_error, because one mode failing is not a
reason to lose the other. The whole thing is one history entry, and
quote_history_id at the top level is its handle — the embedded blocks carry
no ids of their own.
hazmat and the two bobtail flags apply to the R2R half only. The d2d
block is what the carriers quoted, unadjusted, so do not present a D2D price as
including them.
Quote history
Every D2D, non-ephemeral R2R and combined quote is recorded, with a denormalised lane/price summary and a full snapshot. This is the mode-agnostic view of "what have we quoted".
curl "https://api.bidrayl.com/api/v1/quotes/history?mode=r2r&limit=20" \
-H "X-API-Key: $BIDRAYL_API_KEY"
{
"items": [
{
"id": 9931,
"mode": "r2r",
"reference": "R2R-2026-000512",
"origin_zip": "60601",
"origin_city": "Chicago",
"destination_zip": "90001",
"destination_city": "Los Angeles",
"equipment": "53FT",
"service_date": "2026-10-15",
"status": "completed",
"lowest_price_cents": 357500,
"customer_name": "Acme Distribution",
"customer_id": 118,
"quote_pdf_id": 441,
"created_at": "2026-10-09T14:22:05Z"
}
],
"pagination": { "limit": 20, "offset": 0, "total": 96 }
}
| Method | Path | Purpose |
|---|---|---|
GET |
/quotes/history?mode=&company_id=&limit=&offset= |
List. mode is d2d, r2r or combined; omit for all |
GET |
/quotes/history/{id} |
One entry, including its snapshot |
DELETE |
/quotes/history/{id} |
Delete an entry (204) |
POST |
/quotes/history/{id}/pdf |
Generate a customer document |
Filing a quote against a customer
There is no "assign quote to customer" endpoint, on purpose. A quote becomes a customer's quote in one of two ways:
- Log an activity on the company carrying the quote's
quote_history_id. - Generate a customer PDF for it, naming the
company_id.
Both links are first-class, so GET /quotes/history?company_id=118 returns the
union: the quotes that became a document and the ones that did not. Rows carry
customer_name, customer_id and quote_pdf_id, which makes the relationship
answerable in both directions — "what have we quoted them?" and "who did we
quote this lane for?".
Quote documents (PDF)
One endpoint generates a customer-facing PDF for any mode. It is keyed on the history id and dispatches on that entry's mode.
curl -X POST https://api.bidrayl.com/api/v1/quotes/history/9931/pdf \
-H "X-API-Key: $BIDRAYL_API_KEY" -H "Content-Type: application/json" \
-d '{"route_index":0,"company_id":118,"margin_pct":12,"validity_days":14,"include_detail":true}' \
--output quote.pdf
| Entry mode | What to send |
|---|---|
d2d |
carrier_code is required — the entry holds one price per carrier, so there is no sensible default |
r2r |
route_index (default 0) picks the rail option. An entry with no stored rail quote is a 400, not a 404 |
combined |
carrier_code quotes the road leg; omit it to quote the rail leg |
Other body fields, all optional: company_id and contact_id (which file the
document against a customer), margin_pct or margin_flat_cents,
validity_days, include_detail, notes.
By default the response is the document (application/pdf), with
X-Quote-Reference, X-Final-Price-Cents and X-Quote-PDF-Id headers. Send
Accept: application/json to get metadata instead — useful for callers that
cannot hold a binary body:
{ "quote_pdf_id": 441, "quote_history_id": 9931, "reference_number": "Q-2026-00441", "final_price_cents": 400400 }
Re-download later with GET /quote-pdfs/{id}/download, and list a customer's
documents with GET /companies/{id}/quotes.
No PDF is stored as bytes. We keep a snapshot of the quote the document was
made from and re-render on download, so the lane and price stay exactly what
the customer was quoted. Only your logo is re-read live, so rebranding updates
old documents. A record with no snapshot cannot be re-rendered and returns
404.
R2R quotes can also be rendered straight from the quote id with
POST /quotes/ramp-to-ramp/{id}/pdf, which takes the same body.
CRM
Companies, the people at them, the opportunities you are chasing, what was said and what is owed. All of it is available on every plan.
Companies
| Method | Path | Notes |
|---|---|---|
GET |
/companies?q=&limit=&offset= |
q searches name, notes, preferred lanes, website, city and state |
POST |
/companies |
name required; 201 |
GET |
/companies/search?q=&limit= |
Name-only typeahead |
GET |
/companies/{id} |
One company |
PUT |
/companies/{id} |
Update — send only what changes; clear_credit_limit: true nulls the limit |
DELETE |
/companies/{id} |
204 |
GET |
/companies/{id}/contacts |
Its people |
GET POST |
/companies/{id}/activities |
Its interaction log |
GET |
/companies/{id}/quotes |
Documents generated for it |
curl -X POST https://api.bidrayl.com/api/v1/companies \
-H "X-API-Key: $BIDRAYL_API_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Acme Distribution",
"website": "https://acme-dist.com",
"address": { "city": "Savannah", "state": "GA", "zip_code": "31401" },
"status": "ACTIVE",
"payment_terms": "NET30",
"preferred_lanes": "SAV → DFW, SAV → MEM",
"preferred_equipment": ["53FT"],
"tags": ["retail", "contract"]
}'
List rows carry derived last_activity_at and open_task_count, so an account
list reads without a lookup per row.
Contacts
| Method | Path | Notes |
|---|---|---|
GET |
/contacts?q=&company_id=&limit=&offset= |
|
POST |
/contacts |
name required; company_id optional — orphan contacts are allowed |
GET |
/contacts/search?q=&limit= |
Typeahead |
GET PUT DELETE |
/contacts/{id} |
clear_company: true on update unlinks the company |
Deals
A deal is the opportunity as a thing that persists: activities record what was said on a day, a deal records what is being worked, where it stands, and who owns it.
| Method | Path | Notes |
|---|---|---|
GET |
/deals?q=&company_id=&owner_id=&stage=&modes=&opportunity_type=&active=&due_before=&due_after=&sort=&order= |
|
POST |
/deals |
company_id and title required; 201 |
GET |
/deals/pipeline |
Deal count per stage, every stage present |
GET PATCH DELETE |
/deals/{id} |
|
GET POST |
/deals/{id}/attachments |
List, or upload multipart file (max 10 MB) |
GET DELETE |
/deals/{id}/attachments/{attachmentID} |
Download the bytes, or remove |
Stages run PROSPECTING → QUOTING → AWAITING_REVIEW → WON | LOST. Reaching a
terminal stage stamps closed_at; moving back into the pipeline clears it.
Optional fields include transport_modes, opportunity_type
(CONTRACTUAL | SPOT, default SPOT), structured volume (volume_amount +
volume_unit SHIPMENTS|LOADS|CONTAINERS + volume_period WEEK|MONTH|YEAR),
due_date (when the bid is owed back), owner_id (defaults to the caller) and
estimated_value_cents. Updates use clear_volume, clear_due_date and
clear_owner to express removal, which a null cannot.
Activities and tasks both take an optional deal_id and both filter on it, so
"everything we did on this opportunity" is one query per noun. Deleting a deal
keeps the history: activities and tasks survive with deal_id nulled.
Activities
curl -X POST https://api.bidrayl.com/api/v1/companies/118/activities \
-H "X-API-Key: $BIDRAYL_API_KEY" -H "Content-Type: application/json" \
-d '{
"company_id": 118,
"contact_ids": [204],
"quote_history_id": 9931,
"deal_id": 77,
"type": "call",
"result": "NEW_OPPORTUNITY",
"transport_modes": ["INTERMODAL"],
"content": "Walked through the SAV→DFW quote; wants a bid sheet for Q1."
}'
type is note, call, email or meeting. result is optional and one of
NEW_OPPORTUNITY, OPPORTUNITY_WON, OPPORTUNITY_LOST, CONTINUATION.
contact_id (singular) is accepted as well as contact_ids.
Two fields link a quote, and the difference matters:
quote_history_id— works for every mode. Prefer it.quote_id— an R2R quote only.
Both are verified against your tenant: an id that does not exist, or belongs to
another broker, is a 400 rather than a database error.
GET /activities is the same records tenant-wide, newest first, filterable by
q, company_id, deal_id, type, result, modes and after/before
(YYYY-MM-DD, inclusive). Rows from the tenant-wide feed carry company_name;
the per-company list omits it. PATCH /activities/{id} edits one — the owning
company is not editable.
Tasks
| Method | Path | Notes |
|---|---|---|
GET |
/tasks?q=&status=&company_id=&deal_id=&assigned_to=&due_before=&due_after= |
|
POST |
/tasks |
company_id, title, due_date required; 201 |
GET |
/tasks/due-today |
|
GET |
/tasks/overdue |
|
GET PATCH DELETE |
/tasks/{id} |
assigned_to is a user id as a string and is optional: omitted, it defaults to
the calling user, and stays unassigned for an API key, which belongs to a
tenant rather than a person. Resolve assignable people with
GET /tenant/assignees, which returns id, name and email and is
available to any caller — picking a task owner is not user management.
Tasks link quotes with the same two fields and the same checks as activities.
Enum vocabularies
| Field | Canonical values |
|---|---|
task status |
PENDING, COMPLETED, CANCELLED |
task priority |
LOW, NORMAL, HIGH |
company status |
PROSPECT, ACTIVE, INACTIVE, CHURNED |
company default_margin_type |
PERCENTAGE, FLAT |
deal stage |
PROSPECTING, QUOTING, AWAITING_REVIEW, WON, LOST |
deal opportunity_type |
CONTRACTUAL, SPOT |
activity type |
note, call, email, meeting |
Input is case-insensitive on both writes and list filters, and three
aliases are accepted: open → PENDING, medium → NORMAL, fixed →
FLAT. Anything else is a 400 naming the valid set. Responses always carry
the canonical value.
Search across everything
curl "https://api.bidrayl.com/api/v1/crm/search?q=savannah" \
-H "X-API-Key: $BIDRAYL_API_KEY"
{ "query": "savannah", "companies": [ … ], "contacts": [ … ], "deals": [ … ],
"activities": [ … ], "tasks": [ … ], "total": 9 }
Each section holds the same objects as its own list endpoint, capped by limit
(default 5, max 25) per section; total is the hits returned across all of
them. A blank q is a 400 — a global search with no term is a mistake, not a
request for the whole CRM.
Locations
Public, no authentication, so a browser or an embedded widget can call it directly.
curl "https://api.bidrayl.com/api/v1/locations/autocomplete?q=chi&limit=10"
{ "results": [ { "zip": "60601", "city": "Chicago", "state_id": "IL",
"state_name": "Illinois", "lat": 41.8855, "lng": -87.6217 } ] }
GET /locations/zip/{zip} returns a single record. Use autocomplete for
address inputs and pass the resulting ZIP — not free text — to the quote
endpoints.
Tenant settings
| Method | Path | Notes |
|---|---|---|
GET |
/tenant |
Plan, margin defaults, branding, widget flag |
PATCH |
/tenant |
All fields optional; only what you send changes |
GET |
/tenant/seats |
{ "used": 3, "total": 5 } — total: 0 means unenforced |
PUT GET DELETE |
/tenant/logo |
Multipart file upload / image bytes / clear |
GET |
/tenant/assignees |
Users as assignable names |
GET |
/tenant/d2d-carriers |
Every carrier available to you, each with selected |
PUT |
/tenant/d2d-carriers |
(admin) Set the selection |
GET POST |
/tenant/users |
(admin) List / invite. Roles: admin, member |
PATCH DELETE |
/tenant/users/{id} |
(admin) Change role / remove |
Your saved bulk-margin default lives on the tenant as
bulk_r2r_margin: { min_margin_cents, margin_pct, max_margin_cents }.
Carrier selection is opt-out. A carrier with no stored row is queried, so
an unset tenant queries everything and a newly onboarded carrier is available
without anyone opting in. Deselecting one excludes it from
GET /rates/d2d/providers, skips it in POST /rates/d2d, and makes
POST /rates/d2d/providers/{code} return 404. The PUT body is a list
rather than a code-keyed map, and carriers you omit keep their current state:
{ "carriers": [ { "code": "carrier_a", "selected": false } ] }
Widget
An embeddable quote-request form for your own customers' sites. It is the one place a public key is used.
<script
src="https://api.bidrayl.com/widget.js"
data-api-key="pk_…"
data-api-url="https://api.bidrayl.com/api/v1"></script>
Both attributes are required; the script logs an error and does nothing without them.
The widget posts to /widget/quote with the public key in X-API-Key:
{ "company_name": "ACME", "contact_name": "Dana Reed",
"contact_email": "dana@acme.com", "contact_phone": "+1-555-0100",
"origin_zip": "60601", "destination_zip": "90001",
"equipment_type": "53DRY", "ship_date": "2026-10-15",
"quote_type": "R2R" }
{ "status": "ok", "message": "…", "reference_number": "Q-2026-00123" }
quote_type is R2R or D2D. Widget paths accept any origin, by design.
Tenants with widget_enabled: false get 403 widget_disabled — the switch is
under Settings → Widget.
Error reference
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error |
Field-level problems, listed in errors |
| 400 | invalid_json |
The body did not parse |
| 400 | invalid_equipment_code |
Equipment not recognised for that mode |
| 400 | invalid_route_index |
No route at that index on the quote |
| 400 | invalid_margin |
Margin configuration is not coherent |
| 400 | geocoding_failed |
We could not place that address |
| 401 | unauthorized |
Missing, malformed, expired or revoked credential |
| 403 | public_key_forbidden |
A public key was used on a non-widget endpoint |
| 403 | feature_not_entitled |
Your plan does not include this endpoint. Do not retry |
| 403 | account_pending / account_rejected |
The tenant has not been approved |
| 403 | widget_disabled |
Widget quoting is off for this tenant |
| 403 | forbidden |
Authenticated, but not allowed |
| 404 | not_found |
Also company_not_found, contact_not_found, deal_not_found, task_not_found, activity_not_found, quote_not_found, carrier_not_found |
| 404 | no_routes_found |
No routing satisfied the request |
| 404 | no_ramps_near_origin / no_ramps_near_destination |
No ramp within reach of that end |
| 404 | no_dray_rate |
No dray rate covers that end |
| 409 | duplicate_email |
That email already exists |
| 413 | attachment_too_large |
Deal attachments cap at 10 MB |
| 502 | geocoding_error |
Our geocoding service is temporarily unavailable — retry |
| 503 | no_carriers_available |
No D2D carrier is enabled and reachable |
| 500 | internal_error |
Ours. Quote the X-Request-ID |
A lane we cannot route is a 404, not an empty 200: no quote is different
from a free move. no_routes_found also covers a lane whose only routings were
geographically absurd — a detour budget discards those rather than returning a
nonsense price.
Practical notes
R2R is the fast path; D2D is the slow one. An R2R quote is computed here, against rate sheets we already hold, so it returns in a fraction of the time a D2D quote takes — routing and pricing a lane is local work. A D2D quote is bounded by somebody else's API: we call each carrier and wait, so the batch takes as long as the slowest carrier answers, and no amount of work on our side changes that.
What that means for a client: R2R (single and combined) needs no special handling, while D2D wants a generous timeout (30–60s). If a person is watching a screen, drive D2D through the per-carrier endpoint and fill in cards as they land, rather than blocking on the batch.
There is no published per-key rate limit today. Be reasonable — serial rather than parallel bursts — and use the bulk endpoints for bid sheets instead of firing hundreds of single quotes.
Store the ids we hand back. quote_history_id is the handle for filing a
quote against a customer and generating its document; quote_number and
reference_number are what customers quote back to you.
Health check. GET /health (unversioned, no auth) returns
{"status":"ok","version":"<build>"}.
Versioning. The path carries the version. We add fields without warning, so
parse tolerantly and ignore what you do not recognise; we do not remove or
repurpose a field within v1.
Something missing, or a field that does not behave as documented? Tell us at
support@bidrayl.com with the X-Request-ID — that is how we find the call.