Rates API
Partner-facing reference for reading freight rates from FreightAtScale over HTTP.
apiKey and baseUrl after importing.
This document is the whole contract: everything it does not describe is not part of the API and may change without notice. It is written to be handed to an integrating partner as-is.
- Base URL:
https://api.freightatscale.com - Version prefix:
/public/v1 - Transport: HTTPS only. Never send your key over plain HTTP — a request that reaches us unencrypted has already exposed the credential in transit, and it should be treated as compromised and rotated.
Authentication
Every request needs the API key issued to you. Send it as a bearer token:
Authorization: Bearer fas_live_a1b2c3d4e5f6_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
An X-API-Key: <key> header is accepted as an equivalent alternative if your HTTP client makes
that easier. Send one or the other, not both.
Key format. fas_<environment>_<key id>_<secret>.
| Segment | Meaning |
|---|---|
fas |
Namespace. Lets a key be recognised in a log or a config file at a glance. |
live / test |
Which environment the key belongs to. Keys are issued per environment and exist only there, so a test key does not authenticate against production. |
| key id | Public. Safe to quote in a support ticket — it is how we identify your key without you sending us the secret. |
| secret | The credential itself. |
The key is shown once, at issuance, and is not recoverable. We store only a hash of it. If it is lost, ask us to issue a replacement; if it is exposed, tell us and we will revoke it — revocation takes effect on the very next request, with no deploy or delay.
Handling. Treat the key like a password: keep it server-side, in a secret store or environment variable. Do not embed it in a browser, a mobile app, or anything else an end user can read — a key is not scoped to a single caller, so anyone holding it has everything it grants. Do not put it in a URL query string, where it lands in access logs and browser history.
Scopes. A key carries an explicit list of what it may reach. Today the only scope is
rates:read. A request to an endpoint outside your key's scopes returns 403, not 404 — you
will always be able to tell "not allowed" from "not there".
Keys may also carry an expiry date. An expired key returns 401, the same as an invalid one.
Rate limits
Your key has its own request-per-minute allowance, independent of any other key and of the IP you
call from. GET /public/v1/whoami reports it as rateLimitPerMinute. The default for a new key is
60 requests/minute and the current ceiling for a single key is 600/minute; if your
integration needs more than the default, ask — it is a per-key setting, not a code change.
A separate per-IP limit also protects the endpoint against unauthenticated traffic. It sits above the per-key ceiling, so it will not affect a single partner calling within their own allowance.
The allowance is shared across every endpoint in this document, so it is a budget for the whole API rather than per route. Every response carries your current standing:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Your allowance for the window. |
X-RateLimit-Remaining |
Requests left in the current window. |
X-RateLimit-Reset |
Seconds until the window resets. |
Exceeding it returns 429 with a Retry-After header, in seconds. Wait that long and retry;
retrying sooner just burns the next window too. Requests rejected with 401 do not count
against your allowance, so a misconfigured key cannot exhaust your budget.
Errors
Every error has the same shape:
{
"statusCode": 401,
"message": "Invalid API key.",
"error": "Unauthorized",
"timestamp": "2026-09-01T09:24:12.000Z",
"path": "/public/v1/rates"
}
message is a string, or an array of strings when several query parameters fail validation at
once.
| Status | When | What to do |
|---|---|---|
| 400 | A parameter is malformed, out of range, or not recognised | Fix the request; the message names the parameter |
| 401 | Key missing, malformed, unknown, revoked, or expired | Check the key; do not retry unchanged |
| 403 | Key is valid but lacks the scope for this endpoint | Ask us to widen the key's scopes |
| 404 | No rate with that id | — |
| 429 | Rate limit exceeded | Wait Retry-After seconds |
| 500 | Our fault | Retry with backoff; if it persists, contact us with the timestamp and path |
Two behaviours worth knowing before you start:
Unknown query parameters are rejected, they are not ignored. ?orderBy=amount returns
400 ["property orderBy should not exist"]. This is deliberate: a silently-ignored typo in a
filter returns a plausible-looking but wrong result set, which is far worse than a loud failure.
Every authentication failure returns the same 401 and the same message, whether the key is
malformed, unknown, revoked, or expired. This is intentional and not a diagnostic gap — the
alternative lets anyone probe which keys exist. Use /whoami to check a key you legitimately hold.
Confirms your key works and reports what it can do. No parameters. Start here when integrating.
curl -H "Authorization: Bearer $FAS_API_KEY" \
https://api.freightatscale.com/public/v1/whoamiconst res = await fetch(
"https://api.freightatscale.com/public/v1/whoami",
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.FAS_API_KEY}`,
},
},
);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { data, meta } = await res.json();import os
import requests
res = requests.get(
"https://api.freightatscale.com/public/v1/whoami",
headers={
"Authorization": f"Bearer {os.environ['FAS_API_KEY']}",
},
timeout=30,
)
res.raise_for_status()
payload = res.json()<?php
$ch = curl_init('https://api.freightatscale.com/public/v1/whoami');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('FAS_API_KEY'),
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("Rates API returned $status: $body");
}
$payload = json_decode($body, true);import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.freightatscale.com/public/v1/whoami"))
.header("Authorization", "Bearer " + System.getenv("FAS_API_KEY"))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("Rates API returned " + response.statusCode());
}
String payload = response.body();using System.Net.Http.Headers;
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(
HttpMethod.Get,
"https://api.freightatscale.com/public/v1/whoami");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("FAS_API_KEY"));
using var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync();package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
req, err := http.NewRequest("GET", "https://api.freightatscale.com/public/v1/whoami", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("FAS_API_KEY"))
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
panic(fmt.Sprintf("Rates API returned %d: %s", res.StatusCode, body))
}
fmt.Println(string(body))
}{
"partner": "Acme Logistics",
"keyPrefix": "fas_live_a1b2c3d4e5f6",
"scopes": ["rates:read"],
"rateLimitPerMinute": 60
}
Searches the rate library. Requires the rates:read scope.
Query parameters
All are optional. Every filter is combined with AND; a filter that accepts a list matches any of its values (OR).
| Parameter | Type | Default | Notes |
|---|---|---|---|
origin |
string list | — | Port name, UN/LOCODE, or a common alias |
destination |
string list | — | Same |
carrier |
string | — | Case-insensitive exact match. See /rates/carriers |
mode |
air | sea |
— | |
equipment |
string list | — | Container codes, e.g. 40HC, 20GP |
rateType |
contract | spot |
— | See rate types |
search |
string | — | Free-text across origin, destination and region |
includeExpired |
boolean | false |
See validity |
page |
integer ≥ 1 | 1 |
|
limit |
integer 1–200 | 50 |
List parameters accept either repeated keys or one comma-separated value — both forms are equivalent:
?origin=INNSA&origin=INMAA
?origin=INNSA,INMAA
If a port name itself contains a comma, send it as its own repeated parameter.
Port matching is deliberately forgiving. Rates originate in carrier spreadsheets that name
ports however the carrier chose, so a filter term is expanded against known port names, codes and
aliases before matching. INNSA, Nhava Sheva and Jawaharlal Nehru all find a rate the sheet
recorded as Nhava Sheva / Mumbai. Prefer the UN/LOCODE where you have it — it is the least
ambiguous.
Unspecified is not incompatible. Some source sheets do not state a mode or a container type.
Those rows match any mode or equipment filter rather than being excluded, because a blank
cell means "not stated", not "does not apply". Check the returned mode and equipment fields
(they may be null) rather than assuming the filter guaranteed them.
Example
curl -H "Authorization: Bearer $FAS_API_KEY" \
"https://api.freightatscale.com/public/v1/rates?origin=INNSA&destination=Rotterdam&equipment=40HC&mode=sea"const res = await fetch(
"https://api.freightatscale.com/public/v1/rates?origin=INNSA&destination=Rotterdam&equipment=40HC&mode=sea",
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.FAS_API_KEY}`,
},
},
);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { data, meta } = await res.json();import os
import requests
res = requests.get(
"https://api.freightatscale.com/public/v1/rates?origin=INNSA&destination=Rotterdam&equipment=40HC&mode=sea",
headers={
"Authorization": f"Bearer {os.environ['FAS_API_KEY']}",
},
timeout=30,
)
res.raise_for_status()
payload = res.json()<?php
$ch = curl_init('https://api.freightatscale.com/public/v1/rates?origin=INNSA&destination=Rotterdam&equipment=40HC&mode=sea');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('FAS_API_KEY'),
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("Rates API returned $status: $body");
}
$payload = json_decode($body, true);import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.freightatscale.com/public/v1/rates?origin=INNSA&destination=Rotterdam&equipment=40HC&mode=sea"))
.header("Authorization", "Bearer " + System.getenv("FAS_API_KEY"))
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("Rates API returned " + response.statusCode());
}
String payload = response.body();using System.Net.Http.Headers;
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
using var request = new HttpRequestMessage(
HttpMethod.Get,
"https://api.freightatscale.com/public/v1/rates?origin=INNSA&destination=Rotterdam&equipment=40HC&mode=sea");
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("FAS_API_KEY"));
using var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadAsStringAsync();package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
func main() {
req, err := http.NewRequest("GET", "https://api.freightatscale.com/public/v1/rates?origin=INNSA&destination=Rotterdam&equipment=40HC&mode=sea", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("FAS_API_KEY"))
client := &http.Client{Timeout: 30 * time.Second}
res, err := client.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
panic(fmt.Sprintf("Rates API returned %d: %s", res.StatusCode, body))
}
fmt.Println(string(body))
}{
"data": [
{
"id": "aaaaaaaa-0000-4000-8000-000000000001",
"carrier": "MAERSK",
"rateType": "contract",
"mode": "sea",
"origin": "Nhava Sheva / Mumbai",
"destination": "Rotterdam",
"region": "North Europe",
"equipment": "40' HC",
"amount": 1850,
"currency": "USD",
"freeDays": 14,
"surcharges": [
{ "code": "BAF", "amount": 120, "currency": "USD", "unit": "CONTAINER", "paymentTerm": "PREPAID" }
],
"validFrom": "01 Sep",
"validTill": "30 Sep",
"validFromDate": "2026-09-01",
"validTillDate": "2026-09-30",
"updatedAt": "2026-09-01T09:22:47.002Z"
}
],
"meta": { "page": 1, "limit": 50, "total": 1, "totalPages": 1 }
}
Pagination
meta.total is the count across all pages, meta.totalPages the number of pages at the current
limit. Results are ordered oldest-first and that order is stable, so paging through a result set
will not show you the same rate twice.
Lists the carriers present in the library and which rate types exist for each. Use it to discover
valid carrier filter values rather than guessing at our spelling.
{
"data": [
{ "carrier": "HAPAG-LLOYD", "rateTypes": ["spot"] },
{ "carrier": "MAERSK", "rateTypes": ["contract", "spot"] }
]
}
Returns one rate by its id. 404 if there is no such rate. Ignores expiry — a rate you already
hold an id for is returned whether or not it has since expired, so a stored reference does not
silently become a 404.
The rate object
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) | Stable. Safe to store as a reference |
carrier |
string | |
rateType |
string | contract or spot — see below |
mode |
"air" | "sea" | null |
null = not stated in the source |
origin |
string | Port as the source names it, not a code |
destination |
string | Same |
region |
string | null | Carrier's own trade-region grouping, when stated |
equipment |
string | null | Normalised where recognised (40' HC, 20' GP) |
amount |
number | The rate. Always paired with currency |
currency |
string | ISO 4217 |
freeDays |
integer | null | Free detention/demurrage days, when stated |
surcharges |
array | null | See below |
validFrom / validTill |
string | null | Validity as written on the source sheet, e.g. "01 Sep" |
validFromDate / validTillDate |
string (YYYY-MM-DD) | null |
Resolved calendar dates. See validity |
updatedAt |
string (ISO 8601) | Last change to this rate |
Rate types
| Value | Meaning |
|---|---|
contract |
From a negotiated carrier contract or a published rate sheet |
spot |
A live or recently-fetched spot quote |
These are the only two values this API returns or accepts. Passing anything else to the rateType
filter returns 400 listing the valid values.
Amounts
amount is a JSON number with at most 2 decimal places. What one unit is depends on the trade:
for FCL it is per container of the stated equipment; for air it is typically per kilogram. The
API does not currently state the unit explicitly — if that matters to your integration, tell us
and we will add it as a field rather than have you infer it.
Surcharges are not included in amount. A landed cost is amount plus the applicable
entries in surcharges, each of which looks like:
{ "code": "BAF", "amount": 120, "currency": "USD", "unit": "CONTAINER", "paymentTerm": "PREPAID" }
unit is one of TEU, CONTAINER, SHIPMENT, PERCENTAGE, UNKNOWN; paymentTerm one of
PREPAID, COLLECT, UNKNOWN. A surcharge's currency may differ from the rate's.
Validity
Two pairs of fields, because they answer different questions.
validFrom / validTill are the raw text from the source sheet, e.g. "01 Sep". Carrier
sheets routinely omit the year, so this text is preserved exactly as received and never
"corrected".
validFromDate / validTillDate are our resolved calendar dates, as YYYY-MM-DD. They are a
best-effort interpretation of the text above, and either may be null when a date could not be
determined confidently.
A null validTillDate means "no expiry could be determined" — it does not mean "never
expires". Do not treat such a rate as valid indefinitely. Check validTill and confirm with us
if the distinction matters commercially.
By default the search excludes rates whose validTillDate is in the past. Rates with a null
validTillDate are never excluded, since we cannot prove they are stale. Pass
includeExpired=true to see expired rates too.
Freshness
Rates are a library, not a live quoting engine. They change when a new sheet is imported or a
quote is received — hours to days, not seconds. Polling more than a few times an hour will not
surface anything new; updatedAt is the reliable way to detect change. A rate returned here is
reference pricing, not a firm offer or a booking guarantee. Confirm commercially before relying
on it.
What this API does not cover
This endpoint publishes carrier contract and spot pricing. It is not a complete view of every rate we hold, and it is not intended to be — some pricing is commercially confidential and is not published through any partner channel.
Practically, this means a lane you know we service may return no rows. That is not an error and
not a gap in your integration: it means we hold no publishable contract or spot rate for it.
Treat an empty data array as a normal outcome, and ask us directly about a lane that matters to
you. GET /public/v1/rates/carriers likewise lists only carriers with publishable rates, so it is
the authoritative list of what you can filter by.
Versioning and change policy
The version lives in the path (/public/v1). Within v1 we may add new endpoints, add new fields
to existing responses, and add new optional query parameters.
Your client must tolerate new fields appearing in a response. Parse defensively — read the fields you need rather than requiring an exact match on the object's shape.
We will not, within v1, remove or rename a field, change a field's type, or change the meaning of
an existing value. Anything of that kind ships as /public/v2, and v1 keeps running alongside it
for an agreed migration window.
Support
Quote your keyPrefix (never the full key) plus the timestamp and path from the error body.