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": "[email protected]", "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_* and all_in_* amounts, plus transit_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:

  1. Log an activity on the company carrying the quote's quote_history_id.
  2. 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: openPENDING, mediumNORMAL, fixedFLAT. 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": "[email protected]", "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 [email protected] with the X-Request-ID — that is how we find the call.

Get a key and quote
your first lane.

Sign up, create a private API key under Settings → API keys, and everything above is live against your own account.