API documentation

Integrate the SAMO-TRANS exchange into your TMS or CRM

API documentation

Webhooks

Listing events and the feed of new offers on your lanes, X-Samo-Signature, retries.

A webhook is an HTTPS address on your side that SAMO-TRANS posts to as soon as an event happens. It is cheaper and faster than polling the exchange on a schedule.

Events

EventSent when
proposal.createdone of your listings was created (from the website or the API)
proposal.updatedone of your listings changed
proposal.deletedone of your listings was deleted
proposal.matcheda listing from another company appeared and matches your criteria

The first three concern your own listings and are useful for keeping state in sync when listings are edited both in the dashboard and through the API.

proposal.matched is the opposite: a feed of new offers on your lanes. Instead of polling search every minute, you receive the listing the moment it is published.

Setting one up

Webhooks are configured by the company owner in the dashboard: Settings → API, in the webhooks block below the key list. They cannot be created with an API key — deliberately, so subscription management stays with someone who has dashboard access.

You provide:

  • URLhttps:// only. It must be publicly reachable: private, loopback and link-local addresses (including 169.254.169.254) are rejected.
  • Events — one or more from the list above.
  • Criteria — required for, and only valid with, proposal.matched.

The secret is shown once, right after creation. Save it — it is what verifies the signature. A lost secret cannot be recovered; you would have to create the webhook again.

Criteria for proposal.matched

Criteria must narrow the exchange by at least one field — a subscription to "everything" is rejected.

FieldValue
typeCARGO or TRANSPORT
carTypesbody-type codes (up to 50) from /reference/car-types
weight{ "min": …, "max": … } in tonnes — at least one bound required
volume{ "min": …, "max": … } in m³
date{ "start": "…", "end": "…" } — loading date
from, toup to 25 locations in each direction

A location looks like this:

{ "type": "country",  "value": "PL" }
{ "type": "region",   "value": "UA-59", "countryCode": "UA" }
{ "type": "locality", "value": "3678531", "countryCode": "UA" }

For region the value is a region code; for locality it is the osmId you get from /reference and /localities/search.

Example — "cargo from Volyn to Poland, 15 tonnes and up, tilt trailers":

{
  "type": "CARGO",
  "from": [{ "type": "region", "value": "UA-07", "countryCode": "UA" }],
  "to": [{ "type": "country", "value": "PL" }],
  "weight": { "min": 15 },
  "carTypes": ["tent"]
}

Delivery format

Each event is a separate POST with this body:

{
  "event": "proposal.matched",
  "timestamp": "2026-08-05T09:14:22.481Z",
  "data": { "id": "…", "type": "CARGO", "route": {}, "…": "…" }
}

Headers:

Content-Type: application/json
User-Agent: SAMO-TRANS-Webhooks/1
X-Samo-Event: proposal.matched
X-Samo-Webhook-Id: <webhook id>
X-Samo-Signature: sha256=<hex>

In proposal.matched the contacts are stripped: it is someone else's listing, and access to contacts depends on your plan. When the event arrives, fetch the full record with your key — GET /proposals/{id}.

For proposal.deleted the data field contains only id.

Verifying the signature

The signature is an HMAC-SHA256 of the raw request body using the webhook secret. Compute it before parsing JSON: any normalisation (reordered keys, whitespace) breaks the match.

const crypto = require("crypto");

function isValidSignature(rawBody, headerValue, secret) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const received = String(headerValue || "").replace(/^sha256=/, "");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(received, "hex");
  // Constant-time compare, so a signature cannot be guessed by timing.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: the signature is over the raw body, so express.json() will not do here
app.post("/samo/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!isValidSignature(req.body, req.get("X-Samo-Signature"), process.env.SAMO_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Acknowledge fast and process asynchronously — you have 10 seconds to respond.
  enqueue(event);
  res.sendStatus(200);
});
<?php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, getenv('SAMO_WEBHOOK_SECRET'));
$received = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_SAMO_SIGNATURE'] ?? '');

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
// ... enqueue ...
http_response_code(200);

Retries and what we expect from your endpoint

  • Any 2xx counts as success. Everything else is a failure.
  • Redirects are not followed: a 3xx is treated as an error, so give us the final address.
  • Timeout is 10 seconds. Respond immediately and do the work in the background.
  • Up to 5 attempts with exponential backoff starting at 5 seconds.
  • Delivery is at-least-once: the same event can arrive twice. Make your handler idempotent — deduplicate on event + data.id + timestamp.
  • Ordering is not guaranteed. If you need the final state of a listing, re-fetch it with GET /proposals/{id}.

Lifecycle

  • A webhook can be deleted in the dashboard; deliveries stop immediately.
  • If the company moves to a plan without API access, all webhooks are deactivated automatically and re-enabled when PREMIUM returns. There is no need to recreate them.