API documentation

Integrate the SAMO-TRANS exchange into your TMS or CRM

API documentation

Examples

Ready-to-run curl for dictionaries, search, listing creation, batches and sync.

All examples assume two environment variables:

export SAMO_API="https://api.samo-trans.com/api/public/v1"
export SAMO_KEY="samo_live_XXXXXXXXXXXXXXXXXXXXXXXX"

Dictionaries

Before creating your first listing, fetch the dictionaries — these are the codes the API accepts. They change rarely, so cache them on your side.

curl "$SAMO_API/reference/car-types"     -H "Authorization: Bearer $SAMO_KEY"
curl "$SAMO_API/reference/vehicle-types" -H "Authorization: Bearer $SAMO_KEY"
curl "$SAMO_API/reference/load-types"    -H "Authorization: Bearer $SAMO_KEY"
curl "$SAMO_API/reference/permits"       -H "Authorization: Bearer $SAMO_KEY"
curl "$SAMO_API/reference/currencies"    -H "Authorization: Bearer $SAMO_KEY"
curl "$SAMO_API/reference/payment-types" -H "Authorization: Bearer $SAMO_KEY"

Each entry carries a code and labels in five languages:

{ "code": "tent", "labels": { "uk": "Тент", "en": "Tent", "ru": "Тент", "de": "Plane", "pl": "Plandeka" } }

The body-type dictionary also includes the specialised codes box_type (box truck), walking_floor_type, coil_carrier_type, timber_truck_type, concrete_mixer_type, and hooklift_type. Always use the codes returned by /reference/car-types rather than storing a local hard-coded copy.

/reference/vehicle-types is a separate vehicle-composition dictionary: truck_with_trailer, truck, tractor_with_semitrailer, tractor, passenger, special_equipment, semitrailer, and trailer. It is not a body type: for example, tractor_with_semitrailer may be combined with either carTypes: ["tent"] or carTypes: ["ref"].

Geography:

curl "$SAMO_API/reference/countries?locale=en" -H "Authorization: Bearer $SAMO_KEY"
curl "$SAMO_API/reference/regions?country=UA&locale=en" -H "Authorization: Bearer $SAMO_KEY"

# locality search → this is where route osmIds come from
curl "$SAMO_API/localities/search?q=Kovel&country=UA&locale=en" \
  -H "Authorization: Bearer $SAMO_KEY"

/localities/search returns three kinds of entry — country, region and locality, in that order (widest first). Tell them apart by type, because their fields differ: a country code lives in countryCode, a region code in regionCode, and only a locality carries an osmId:

[
  { "type": "country",  "countryCode": "UA", "name": "Ukraine", "formatted": "Ukraine, UA" },
  { "type": "region",   "regionCode": "UA-32", "countryCode": "UA", "name": "Kyiv Reg.",
    "formatted": "(UA-32), Kyiv Reg.", "countryName": "Ukraine" },
  { "type": "locality", "osmId": "421866", "name": "Kyiv", "countryCode": "UA",
    "regionCode": "UA-32", "lat": 50.4501, "lng": 30.5234 }
]

Those codes go straight into the search filters: countryCodestartCountries, regionCodestartRegions, osmIdstartLocalities. The country parameter narrows the search to one country, leaving only its regions and localities in the response. A query shorter than 3 characters returns an empty array.

Alongside the cities you may pass startLocalityCountries / endLocalityCountries — the country of each entry in startLocalities / endLocalities, in the same order. Listings posted for that whole country ("any direction") are then returned as well as the ones tied to the city: a shipper who wrote "Ukraine" shows up in a search for Kyiv.

Searching the exchange

curl -G "$SAMO_API/proposals/search" \
  -H "Authorization: Bearer $SAMO_KEY" \
  --data-urlencode "type=CARGO" \
  --data-urlencode "startCountries=UA" \
  --data-urlencode "endCountries=PL" \
  --data-urlencode "minCapacity=15" \
  --data-urlencode "dateFrom=2026-08-10" \
  --data-urlencode "limit=50"

Geo filters are arrays, so repeat the parameter:

curl -G "$SAMO_API/proposals/search" \
  -H "Authorization: Bearer $SAMO_KEY" \
  --data-urlencode "startRegions=UA-07" \
  --data-urlencode "startRegions=UA-05" \
  --data-urlencode "carTypes=tent" \
  --data-urlencode "carTypes=ref"

A single listing:

curl "$SAMO_API/proposals/clx7k2n9p0001/?locale=en" -H "Authorization: Bearer $SAMO_KEY"

Contacts appear in the response only if your plan allows seeing them — otherwise "contact": null.

Creating a listing

Cargo:

curl -X POST "$SAMO_API/proposals/cargo" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tms-order-88213" \
  -d '{
    "startLocations": [{ "type": "locality", "osmId": "3678531", "countryCode": "UA" }],
    "endLocations":   [{ "type": "country",  "countryCode": "PL" }],
    "dataLoadStart": "2026-08-12T00:00:00.000Z",
    "dataLoadEnd":   "2026-08-14T00:00:00.000Z",
    "capacity": 20,
    "volume": 86,
    "description": "Pallets, 20 t, side curtain access",
    "carTypes": ["tent"],
    "loadUnloadTypes": ["side", "back"],
    "carPermits": ["cmr", "t1"],
    "price": 24500,
    "currencyType": "UAH",
    "paymentType": "CASHLESS"
  }'

Transport uses the same body shape and a different path:

curl -X POST "$SAMO_API/proposals/transport" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "startLocations": [ … ], "endLocations": [ … ], "dataLoadStart": "…", "dataLoadEnd": "…", "capacity": 22, "description": "Tilt 92 m³, free from Monday", "vehicleType": "tractor_with_semitrailer", "carTypes": ["tent"] }'

Required fields: startLocations, endLocations, dataLoadStart, dataLoadEnd, capacity, description, carTypes.

vehicleType is optional and is accepted only for a transport listing. Send one code from /reference/vehicle-types; null in a PATCH clears it. Responses always include the field: the selected code, or null for an old or unspecified listing.

description must be 250 characters or shorter. A longer string is not truncated — it fails the request with 400 validation_error. The same rule applies to batch creation and to PATCH.

Contacts are stripped from description before it is stored. Phone numbers, e-mail addresses, @handles and links — including bare domains such as example.com and messenger links (t.me/…, wa.me/…, viber://…) — are removed from the text. Contacts on the exchange are a paid feature and are served in the listing's own contact field, not in free text.

This is not validation: a request with a phone number in the description still succeeds, the listing is simply saved with the cleaned text. The rule is identical for POST /proposals/cargo, POST /proposals/transport, POST /proposals/batch and PATCH /proposals/{id}.

Practical consequence: the description you get back — in the response and on any later GET — may be shorter than the one you sent. Do not compare those strings for equality to decide whether a listing changed. Text without contacts is returned untouched — trip dates (05.09-07.09.2026), tax numbers, weights and prices are never mistaken for a phone number.

Do not send companyId — it always comes from the key; companyId in the body is not ignored, it fails the request with 400 validation_error. The contact person (contactPersonId) is optional — omit it and the contact becomes whoever issued the key. See "Assigning a listing to an employee" below for details.

Temperature range

A refrigerated listing can carry a required temperature range:

{
  "carTypes": ["ref"],
  "temperatureMin": -18,
  "temperatureMax": -12
}

Both bounds are optional and measured in °C; the accepted range is −99…+99. Either bound may be omitted: {"temperatureMax": 2} means "up to +2 °C" and {"temperatureMin": -18} means "from −18 °C". temperatureMin must not be greater than temperatureMax.

The fields are accepted only when carTypes contains a temperature-controlled body — ref, ref-tushe or izoterm. Otherwise the request fails with 400 validation_error rather than being silently ignored: dropping the number quietly would hide a bug in your code.

In responses the range comes back as separate unit-suffixed fields, the same way as capacityTons and volumeM3:

{
  "capacityTons": 20,
  "volumeM3": 86,
  "temperatureMinC": -18,
  "temperatureMaxC": -12
}

When no range is set, both fields are null.

Changing the body type clears the range. A PATCH that moves the listing to a non-temperature-controlled body resets both bounds — otherwise a tented listing would keep a temperature. This is the only case where the server changes these fields without you asking.

Batch creation

Up to 25 listings in one request. The response is always 200, with a per-item result.

curl -X POST "$SAMO_API/proposals/batch" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tms-batch-2026-08-05-01" \
  -d '{
    "items": [
      { "type": "CARGO",     "startLocations": [ … ], "endLocations": [ … ], "…": "…" },
      { "type": "TRANSPORT", "startLocations": [ … ], "endLocations": [ … ], "…": "…" }
    ]
  }'
{
  "created": 1,
  "failed": 1,
  "results": [
    { "index": 0, "status": "created", "proposal": { "id": "clx…", "…": "…" } },
    { "index": 1, "status": "error", "error": { "code": "validation_error", "message": "capacity must be a number" } }
  ]
}

A batch consumes as many units of the daily quota as it has items. If the quota would not cover it, the whole request is rejected with 429 quota_exceeded and nothing is created.

Your own listings

# list (only the company the key belongs to)
curl -G "$SAMO_API/proposals/my" \
  -H "Authorization: Bearer $SAMO_KEY" \
  --data-urlencode "status=ACTIVE" \
  --data-urlencode "limit=100"

# partial update — send only the fields that changed
curl -X PATCH "$SAMO_API/proposals/clx7k2n9p0001" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "price": 26000, "description": "Updated: part loads possible" }'

# push back to the top of search
curl -X POST "$SAMO_API/proposals/clx7k2n9p0001/bump" -H "Authorization: Bearer $SAMO_KEY"

# take off air / put back
curl -X POST "$SAMO_API/proposals/clx7k2n9p0001/archive" -H "Authorization: Bearer $SAMO_KEY"
curl -X POST "$SAMO_API/proposals/clx7k2n9p0001/restore" -H "Authorization: Bearer $SAMO_KEY"

# delete permanently
curl -X DELETE "$SAMO_API/proposals/clx7k2n9p0001" -H "Authorization: Bearer $SAMO_KEY"

Worth knowing about these actions:

  • bump is available to the listing's author, its contact person, and an owner or manager of the owning company — the same rights as update, archive and delete. Bumping too soon or exhausting the bump limit returns 400 validation_error with the reason in message.
  • archive and restore are idempotent: calling either on a listing already in that state returns 200 and changes nothing.
  • restore is not free: putting a listing back on air resets its createdAt and view counter, and it occupies an active-listing slot on your plan. At the cap you get a 400.
  • Touching another company's listing returns 404, not 403.
  • description in PATCH goes through the same contact stripping as on creation (see "Creating a listing"). A field absent from the body is not touched at all.

Employees

# list the staff of the company the key belongs to
curl "$SAMO_API/employees" -H "Authorization: Bearer $SAMO_KEY"

# how many listings a specific employee is on — worth checking before deactivating them
curl "$SAMO_API/employees/clx9f4k7r0004/proposals-count" -H "Authorization: Bearer $SAMO_KEY"

# temporarily block access
curl -X PATCH "$SAMO_API/employees/clx9f4k7r0004/status" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "DEACTIVATED" }'

# restore access
curl -X PATCH "$SAMO_API/employees/clx9f4k7r0004/status" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "ACTIVE" }'

# change role
curl -X PATCH "$SAMO_API/employees/clx9f4k7r0004" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "role": "DISPATCHER" }'

List response:

[
  {
    "id": "clx9f4k7r0004",
    "userId": "clx2h6m3t0005",
    "email": "[email protected]",
    "name": "Оксана Ковальчук",
    "role": "DISPATCHER",
    "status": "ACTIVE",
    "invitedAt": "2026-01-02T10:00:00.000Z",
    "joinedAt": "2026-01-03T09:30:00.000Z",
    "avatar": null,
    "phones": [{ "phone": "+380501234567", "isPrimary": true, "hasTelegram": true, "hasWhatsApp": false, "hasViber": false }]
  }
]

Note the two different identifiers: id is the membership and goes in the path of these endpoints; userId is the person, and that is what a listing's contactPersonId takes.

Deactivating someone does not unpublish their listings — those stay on air and keep showing them as the contact. Check proposals-count first and reassign with PATCH /proposals/{id} if needed.

The company owner is always in the list, with role OWNER. That membership cannot be edited through the API: PATCH /employees/{id} and PATCH /employees/{id}/status on it return 400 validation_error.

Creating and deleting employees is not available through the API — that is done in the dashboard.

Assigning a listing to an employee

curl -X POST "$SAMO_API/proposals/cargo" \
  -H "Authorization: Bearer $SAMO_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "startLocations": [{ "type": "country", "countryCode": "UA" }],
    "endLocations":   [{ "type": "country", "countryCode": "PL" }],
    "dataLoadStart": "2026-08-12T00:00:00.000Z",
    "dataLoadEnd":   "2026-08-14T00:00:00.000Z",
    "capacity": 20,
    "description": "Pallets",
    "carTypes": ["tent"],
    "contactPersonId": "clx2h6m3t0005"
  }'

contactPersonId is the userId from the employee list. Only the company owner and employees with status ACTIVE are accepted; anyone else fails with 400 validation_error. Omit the field and the contact becomes whoever issued the key.

Counterparty lookup

curl "$SAMO_API/companies/clx4a1b2c0001" -H "Authorization: Bearer $SAMO_KEY"
curl "$SAMO_API/companies/clx4a1b2c0001/reviews?page=1&limit=20" -H "Authorization: Bearer $SAMO_KEY"

Only approved reviews are returned, and limit is capped at 50.

Incremental sync

Pull a delta instead of walking the whole exchange:

curl -G "$SAMO_API/proposals/search" \
  -H "Authorization: Bearer $SAMO_KEY" \
  --data-urlencode "updatedSince=2026-08-05T09:00:00.000Z" \
  --data-urlencode "limit=100"

Then page through nextCursor, deduplicating by id. The details and pitfalls are in Pagination and sync.