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"].
The cargo name dictionary is open-ended and grows through moderation, so it is not listed in full but searched — exactly like the form on the website:
curl -G "$SAMO_API/reference/cargo-names" \
-H "Authorization: Bearer $SAMO_KEY" \
--data-urlencode "q=malt" \
--data-urlencode "locale=en"
[
{
"id": 5,
"label": "barley malt",
"labels": { "uk": "солод ячмінний", "en": "barley malt", "ru": "солод ячменный",
"de": "Gerstenmalz", "pl": "słód jęczmienny" }
}
]
The search covers all five languages and synonyms; label is in the locale you ask for
(uk, en, ru, de, pl; omitted or unknown means uk). A query shorter than 2 characters
returns an empty array; the response holds at most 10 entries, best match first. Entries hidden
by moderation are not returned. The id goes into a listing as cargoNameId — see
"Cargo name" below.
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: countryCode → startCountries,
regionCode → startRegions, osmId → startLocalities. 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,
"cargoNameId": 5,
"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,
carTypes, and description — which may be left out only on a cargo listing that carries a
cargo name (cargoNameId or cargoNameText, see below). The note is then stored empty and the
response carries "description": "".
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.
Cargo name
What is being shipped is a field of its own on a cargo listing, not part of the note. On the exchange it is shown as a badge on the card and translated into the reader's language. Send one of the two:
cargoNameId— an entryidfrom/reference/cargo-names. The listing is then found by carriers searching in any language, and the label is shown in theirs.cargoNameText— your own wording when nothing in the dictionary fits. Contacts are stripped from it the same way as fromdescription; what is left must be 2–60 characters with at least one letter. If the text equals the Ukrainian name or a synonym of an active entry (ignoring case and extra spaces), the server stores the listing as that entry: the response carries theid, not your text.
The field is optional: a listing without it is published as before. Responses and webhooks always include it, in one of three shapes:
{ "cargoName": { "id": 5, "labels": { "uk": "солод ячмінний", "en": "barley malt", "ru": "солод ячменный", "de": "Gerstenmalz", "pl": "słód jęczmienny" }, "text": null } }
{ "cargoName": { "id": null, "labels": null, "text": "Paving tiles" } }
{ "cargoName": null }
null means a transport listing, or a cargo listing published without a name (including every
one created before the field existed).
Both fields at once, a name on a transport listing, an unknown or hidden id, or text without
letters is 400 validation_error, with the reason in message. One exception for hidden
entries: a PATCH that sends the same cargoNameId the listing already has goes through — the
entry may have been hidden after the listing was published. In a PATCH, a field absent from
the body is left untouched and null counts as "not sent": a cargo name cannot be cleared
through the API, only replaced.
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:
bumpis available to the listing's author, its contact person, and an owner or manager of the owning company — the same rights asupdate,archiveanddelete. Bumping too soon or exhausting the bump limit returns400 validation_errorwith the reason inmessage.archiveandrestoreare idempotent: calling either on a listing already in that state returns200and changes nothing.restoreis not free: putting a listing back on air resets itscreatedAtand view counter, and it occupies an active-listing slot on your plan. At the cap you get a400.- Touching another company's listing returns
404, not403. descriptioninPATCHgoes through the same contact stripping as on creation (see "Creating a listing"). A field absent from the body is not touched at all.cargoNameId/cargoNameTextinPATCHreplace the cargo name;nullchanges nothing (see "Cargo name").
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.