Logistivo CLI: run your logistics operation from a terminal and from your ERP

One command catalog feeds three surfaces: the lg terminal client, the AI assistant inside the dashboard, and the channel external AI assistants connect to. A capability is written once and appears in all three. This page is a contract. The command table below is not hand-written; it is generated from the live command registry on every request.

What is the Logistivo CLI?

The Logistivo CLI (lg) is a command line client that runs the freight, demand, bid, export-document, stock, invoice and customs-tariff operations of your Logistivo account from a terminal or a script. The commands are not baked into the client: the client pulls the command catalog from the server and draws itself from it. The same commands can be called over HTTP without installing anything — that is the path ERP and CRM integrations use.

What is live today, and what is on the way

This section sits at the top of the page on purpose. The most expensive mistake an integration document can make is to describe an endpoint that is not open yet as if it were: the integrator writes the code, the endpoint returns 404, and trust does not come back.

Because the contract is frozen, the integration code you write today keeps working as the remaining pieces open. Fields you do not know must be ignored; that rule is what lets us add without breaking.

What makes this different: the client knows no commands, it draws them

This is the single decision that makes the rest of the page meaningful. In an ordinary CLI the commands live inside the client; in Logistivo they live on the server.

In the classic arrangement, when the server gains a capability the client needs a new release and the user needs to install it. In between, the two sides know different things — and that is the quietest failure mode in integrations: the script runs, the exit code is 0, and the command simply does not describe what the server can do.

In Logistivo a command's definition is a single record: its name, what it does, its JSON Schema parameters, which roles may see it, whether it needs confirmation and whether its effect is reversible. When lg starts it pulls that catalog and generates its help text, its flags and its input validation from it. The moment a command is added on the server, lg knows it — with no client update.

The same catalog feeds the AI assistant in the dashboard and the channel external AI assistants connect to. A capability is written once and appears in three places — and all three pass through the same role gate, the same confirmation gate and the same audit ledger. There is no separate CLI command inventory; if there were, the three surfaces would quietly diverge within six months.

The naming rule — how command names grow

The catalog will grow. New commands are not named ad hoc; six rules are applied. We publish them here because these are the names your integration will be writing for years, and their predictability is worth something to you.

Install and identity

lg is a single-file, dependency-free Node script (Node 18+). It was written that way so it can be copied onto a customer's ERP server as one file: a client that needs npm install never gets there. The client is in early access and the download link will be added to this section when it opens. You do not have to wait for it to build an ERP/CRM integration — the same commands are available over HTTP.

1. Log in — lg login asks for your email and password, writes the returned access token to ~/.logistivo/config.json and fetches the catalog straight away. If email verification is enabled on your account it asks for the code here too — skipping that step would leave you holding a token while every protected endpoint refuses it.

2. For servers and CI: a key, not a password — Non-interactive environments do not type a password into lg login. Issue a key in the panel (Settings › Integration keys) — optionally on behalf of a dedicated, narrowly-scoped team member — and put it in LOGISTIVO_TOKEN, or save it with lg login --api-key. While the variable is set the config file is never read. Details: the "Connecting with an API key" section.

3. Verify and explore the catalog — lg commands lists every command your role can see, grouped by domain; lg help prints that domain's verbs, and lg help prints one command's full parameter list with types and which ones are required. None of that text lives inside the client — all of it comes from the catalog. Remember that the list depends on your role: the verbs of one domain are not the same for a carrier and for a shipper (loads set-status, for instance, is in the carrier catalog only). If a verb is missing, check your role before you check your install.

Never put the key into version control, a CI log or a chat window. If you signed in with an integration key, lg logout revokes it on the server too; in a password session it only deletes the LOCAL copy and says so explicitly. The audit ledger stores your arguments but masks any field whose name contains token, password, IBAN, card or OTP as ***.

Client ergonomics

These features make day-to-day use of the `lg` client easier. None of them change the HTTP contract — skip this section if you integrate over HTTP directly.

Connecting with an API key

An ERP, CRM or script connects to Logistivo with an integration key issued from the panel. The key acts on behalf of a real team member: that person's role decides the command list, and when that person leaves the team the key dies with them. There is no synthetic "service account" and there will not be one — multi-tenant isolation is bound to the user, and an ownerless bridge is the kind that silently keeps writing for years.

1. Issue a key in the panel — Settings › Integration keys › New key. Give it a name ("ERP nightly sync"), pick the scope and optionally shorten the lifetime. The key is shown once, on that screen only; the server does not store it. If you lose it, Rotate issues a new one — the old one becomes invalid immediately.

2. Connect from the terminal — lg login --api-key validates the key, prints the identity and scope, and downloads the command catalog. lg whoami asks the server which user, company, role and scope you are running as. lg logout revokes the key on the server as well (the password-session lg logout cannot do that).

3. Or plain HTTP — The key is an access token carried in the Authorization: Bearer header and works directly against the three command endpoints. On a server or in CI put it in LOGISTIVO_TOKEN; while that variable is set the config file is never read.

Scope is enforced across the whole API surface, not only the command catalog: a read-only key passes read requests (GET/HEAD/OPTIONS) and every other writing request answers 403 forbidden. There are two deliberate exceptions and both are guarded by a finer gate: the command execution endpoint (the decision comes from the command's own read_only flag, because non-writing commands are also called with POST) and a key revoking itself (lg logout, allowed on a read-only key too). Still, issue the key on behalf of the least-privileged member that covers your needs: scope limits what you can write, the role decides which records you can see at all.

The first ten minutes: from the panel to your first call

The most expensive minutes of an integration are the first ten. The four steps below lay out that path in order; the same order exists in the panel as a wizard (Settings › Integration › Setup wizard), and the wizard calls exactly the endpoints described here. A developer without access to the wizard reaches the same place by reading this section.

None of the four steps ends with "I sent it". Whether the key really works is answered by the request ledger; whether the webhook really arrives is answered by the delivery ledger. Both are open in the panel and in lg. A setup that is never measured is a setup that is merely believed to work.

Sandbox: practise writing without touching production

Preflight (dry_run) tells you what a command WOULD do; but whether idempotency really replays, whether the webhook really gets delivered, what the error branches look like and where the credit comes from can only be seen in a REAL write. That is what the sandbox is for: a separate tenant that mirrors your company — same code, same command catalog — and never touches your live ledger.

The sandbox is not a request flag, it is a TENANT. A separate Logistivo company opened for yours: its own user, its own package, its own credit ledger, its own webhook subscriptions. The environment is DERIVED from the key's company and cannot be supplied by the caller, so even if you pick the wrong profile the request stops server-side with 403 ENVIRONMENT_MISMATCH. "I thought I was writing to the sandbox and wrote to production" is structurally impossible.

The surface is identical — measured: the catalog a sandbox key draws is the same one your live key draws (on a shipper key, 185 commands on both sides). A bridge that works in the sandbox works in production; nothing you learn has to be learned twice.

The sandbox does not open empty: it contains generated (NOT copied) seed data — contacts, loads in both directions, and demands. The names are openly invented and the e-mail addresses sit in the @sandbox.invalid domain; no real tenant's row is ever read, cloned or used as a template.

There is deliberately NO reset button, and no record, file or document is ever deleted. If you want a clean start, renewal opens a new generation and the old one stays, retired. A few hundred accumulated rehearsal rows cost nothing next to a deletion routine wired to the wrong tenant.

The command model

There is one grammar and it has no exceptions. To know how to call a command, the only thing you need is what the catalog says about it.

lg loads list --status=in_transport --limit=20 --json

Flag names are schema names and may contain underscores (--load_code, --amount_per_vehicle). That is deliberate: seeing a flag tells you which JSON field it lands in, so moving from the CLI to HTTP requires no name translation. A boolean parameter written without a value is true (--only_open); the opposite is --no-only_open. Write an argument whose value starts with a dash as --flag=value, otherwise a forgotten flag silently swallows the next one as its value.

Real examples

Every command below exists in the catalog today; no name or parameter here is invented. Outputs are abbreviated. The outputs show the `data.result` level; over HTTP the same object arrives inside the `{success, data:{…}}` envelope (see the HTTP command API).

Without the client: the HTTP command API

lg is a convenience, not the door. Catalog and execution both go through three HTTP endpoints; an ERP or CRM integration connects here directly. Identity is a personal access token carried in Authorization: Bearer. EVERY response comes back in the same envelope: `{ "success": bool, "data": { … }, "code": "…", "message": { … } }`. The whole contract lives inside `data` — `contract_version`, `command`, `status` and, as the command's output, `result`; on a failure `error_code`, `error_key` and `error`. The response examples on this page show the `data` object rather than the envelope, to save space; do not forget to unwrap `data` in your own code. `lg --json` also prints the WHOLE envelope, which is why jq paths start with `.data.result…`. The single exception is `/api/public/status`: that endpoint is envelope-free so watchdogs can alert without parsing it.

Asking for confirmation with 200 + ok:false is forbidden and will never happen. The reason is simple: clients treat 200 as success, so a confirmation request returned as 200 would kill the flow silently and nobody would notice. Confirmation is always 409.

The confirmation gate and irreversible commands

Every command in the catalog carries two flags: confirmation_needed (does it require explicit approval before running) and irreversible (can its effect be undone). They are separate questions, and both are enforced on the server rather than left to the client's good manners.

The gate is about responsibility, not about business rules. Placing a bid is the first step toward a freight contract; issuing an invoice creates an accounting entry; a stock movement changes a ledger. None of these should ever be something you can run twice by accident.

The gate is read from the EFFECTIVE command. When one command wraps another — as the generic executor in the chat does — the gate comes from the flags of the command that will actually run, not from the wrapper. With a fixed flag one of two failures would be inevitable: either the invoice command would run unconfirmed, or every country lookup would raise a confirmation card.

read_only and ungated are not the same thing. There are three distinct states: invoices preview is ungated and writes nothing; loads create is gated and writes; invoices create is ungated but writes — a draft comes into being. One test settles it: when the command returns, has a row in the database changed?

It is technically possible for an integration to pass the gate automatically on every call, but it is not advisable. The right pattern is to automate the gate only for commands you know to be reversible, and to route irreversible ones into a human's queue.

Preflight: see what a write command would do, without running it

The most expensive step in wiring an ERP bridge is finding out whether your body is right by ACTUALLY running the command: a draft invoice in the live ledger, a burnt credit, manual cleanup. dry_run ends that — you add one field to the request, the command does not run, and you read what would happen.

The registry holds 207 commands today; 105 of them only read and write nothing. Of the remaining 102 write commands, 85 are gated: called without confirm they already answer 409 and describe what they would do. The gap was the other 17 — invoice drafts, export-document drafts, line items: exactly the chain a bridge wires first. For those, calling was doing.

There was a second gap even on gated commands: schema validation runs AFTER the gate. So a call with a missing argument got the confirmation card first, confirmed it, and only then heard "this field is missing". A preflight answers both in one round trip.

And the most common — and latest-discovered — bridge bug is this: you send a field that is not in the schema (customer_id where recipient_contact_id is expected), the server silently drops it, the command returns 200 and the record lands in the wrong place. The preflight response lists those fields by name under arguments.ignored; no error message ever will.

A preflight writes nothing, and that is a measurement rather than a promise: every SQL statement issued while running EVERY write command (two argument sets, two roles, 178 preflights) was captured. Not one INSERT/UPDATE/DELETE, not one queued job, not one email; row counts across 31 tables and the credit balance were unchanged.

The exit code is the same one the real call would produce — would run 0, confirmation gate 4, idempotency conflict 2, anything else 1 — so "lg … --dry-run || exit" is a meaningful gate in a CI step.

Idempotency: why retrying does not create duplicates

An ERP does not read your error message, does not see your screen, and will resend the same request after a timeout. This section exists for exactly that behaviour.

A call carrying an idempotency_key runs at most once. The second call does not re-execute the command; it returns the recorded result with status replayed. When the network drops, when a request times out, or when a queue picks up the same job twice, you get the first result instead of a duplicate invoice.

Use a key on every writing command. The catalog's read_only field gives you that distinction for free: every command with read_only: false deserves an idempotency key.

Your retry strategy should use exponential backoff and carry the SAME key on every attempt. Generating a fresh key per attempt is the same as not using idempotency at all. Do not send the same key in parallel: at-most-once is guaranteed for sequential retries, not for two requests racing each other.

Incremental sync and pagination

An ERP/CRM bridge should pull what changed, not everything every hour. Every list command — loads list, demands list-own, contacts list, invoices list, teammates list, load-groups list, reservations list, drivers list, tenders list, products search, warehouses search — accepts two shared parameters and returns one shared field.

Why there is no offset: on a live table an offset drifts. A row inserted between two pages makes you skip or double-count a record, silently. The cursor is forward-only, so a record inserted or updated mid-walk lands at the end of the list: nothing is lost, nothing is duplicated.

A full initial load is the same mechanism: start with updated_since=1970-01-01T00:00:00Z and follow next_cursor until it is null. There is no separate “export” endpoint because none is needed.

Same thing with lg: lg loads list --updated_since=2026-09-01T00:00:00Z --limit=100 --json, then lg loads list --cursor= --json.

For the next sync, set updated_since to the largest updated_at you saw. Timestamps have one-second precision, so a record on the boundary may arrive twice — harmless as long as you upsert by external_ref or code on your side. Without updated_since or cursor a command runs in chat mode: human ordering, at most 20 rows, next_cursor null and truncated saying “there is more”. Do not use that mode for sync.

Machine-readable error keys for this section: INVALID_CURSOR, INVALID_UPDATED_SINCE, DUPLICATE_EXTERNAL_REF, EXTERNAL_REF_TOO_LONG, EXTERNAL_REF_INVALID. All of them answer HTTP 422 with error_code: "failed" and result.error_key in the body. Branch on the key, never on the text.

Your own key: external_ref

Do not make our auto-increment ids (invoice_id, contact_id) the business key in your ERP; they are opaque and private to your company. Loads, demands, contacts, invoices, products and warehouses carry your key instead.

Outgoing webhooks: we POST to you when something happens

Everything up to here was pull: you ask, we answer. An ERP bridge had to ask once a minute to learn that a load changed status, an invoice was issued or a bid arrived. Not any more: register an https address, pick the events you care about, and a signed POST arrives when they happen.

Push was added NEXT TO pull, not instead of it. Incremental sync (updated_since + cursor) is untouched and still the right tool: the first full load, backfilling history and answering "did I miss anything" all live there. Webhooks cut the latency from minutes to seconds. A healthy bridge uses both — the webhook as a trigger, the sync as a safety net.

The payload is a SIGNAL, not a copy of the record. It carries the identifiers (id, your own external_ref, status, time); you fetch the detail with your own key over GET, and that call already passes your own authorisation gates. The exposure surface therefore stays at identifier level. Two fields are deliberately absent: the identity of the company that placed a bid (blind bids appear under an alias in the panel; putting the identity in a webhook would pierce that privacy through a second channel) and reservation contact details (no need to move personal data through an extra channel).

Event names are FROZEN. If the meaning of a name changes, a new name is opened and the old one lives through a deprecation window. Adding a new field to the payload does NOT bump the contract version — a consumer must ignore fields it does not know.

Event catalog

Every one of the 13 events below is backed by an event class that is ACTUALLY dispatched. There are no "would be nice" names in the catalog: an event that is announced but never fired is an event the integrator waits for forever, and that failure is silent. This table is not hand-written either — it is drawn from the live catalog on every page load.

invoice.payment_recorded is deliberately ABSENT: that event class exists in the code but is dispatched from nowhere. The payment event will be added the day the class is genuinely fired — not before. Announcing an event early is worse than never adding it.

Verifying the signature

Every request carries an X-Logistivo-Signature header: t=,v1=. The signed string is "." and the algorithm is HMAC-SHA256. The v1 prefix keeps the scheme versionable: if a second scheme ever arrives the header carries t=…,v1=…,v2=… and you pick the one you know.

We publish two dependency-free reference receivers, one for Node and one for plain PHP. Both apply the four rules above and test themselves with --selftest: valid signature, tampered body, wrong secret, out-of-window timestamp, malformed header, the re-serialisation trap and both kinds of repetition (same delivery / same fact). The verifier is the exact counterpart of the code we use ourselves — we consume the contract we publish.

Delivery, retries and muting

Your receiver will not always be up. The rail assumes that: a transient failure enters the retry schedule, a permanent one mutes the subscription — and the REASON for muting is always recorded.

The muting reason is machine-readable and stable (delivery_failures, endpoint_gone, unsafe_target, key_revoked, by_user) and appears both in the panel and in `lg webhooks list`. Muting without a reason would leave "why is nothing arriving" unanswerable. Re-enabling resets the counter and the stamp.

You write the address; OUR server makes the request. Without a gate, a tenant who entered a cloud metadata address could make us issue requests from inside our own network. So the address is checked both when it is registered and on EVERY delivery:

Health and status: nobody should notice a silent bridge before you do

An integration bridge rarely dies loudly. It usually goes quiet in the small hours: a token expires, the person the key runs on behalf of leaves the team, a scope is narrowed, or the receiving endpoint starts returning 403. The first to notice is almost always the customer. lg status exists to reverse that order.

One command answers two questions at once, because during an outage both are asked at the same moment: is the platform up, and is my bridge healthy. The platform line works without a token — during a failure the token itself is the first suspect, and tying the status question to it would leave you unanswered exactly when you need an answer.

The health summary keeps no new ledger; it folds three existing sources into a single sentence: the daily usage counter (how much you called and how much failed), the request ledger (which request, when, and why it was rejected) and the outgoing webhook delivery ledger (did the event reach the receiver). The error rate is always read from the daily counter; the detail ledger is capped and says so plainly when the cap is reached.

The same summary appears in the panel: under Settings › Integration › Keys every row carries a badge and, beneath it, the reason. Thresholds are not computed client-side; whatever level the server assigned is what both the panel and lg status paint, so the two screens can never say different things about the same key.

Your watchdog needs no token. The endpoint returns status words only; it carries no version, host name, queue name, job count, error text or customer data — and it cannot.

The health summary is produced with a constant number of queries per request; the query count does not grow with the number of keys. For detail, read that key's recent requests with lg logs and the outgoing bridge with lg webhooks list.

Errors and exit codes

What a machine branches on is a stable string. The text in the error field targets a human, is localised, and changes without notice — an integration that branches on message text is not supported, and its breaking is not a bug.

Two taxonomies, two fields. error_code is the transport layer (the table above) and is what you branch on first. error_key is the stable business-rule key inside failed — INSUFFICIENT_CREDITS, NOT_FOUND, DUPLICATE_CONTACT, INSUFFICIENT_STOCK — published as a dictionary in the catalog (error_keys) and below. A command that does not yet emit a key returns error_key: null; treat a key missing from the dictionary as a generic failure.

Stability promise

The most expensive question in an integration contract is “will the code I write today still run tomorrow?” One number answers it: contract_version in the catalog, and the X-Contract-Version header on every response. It answers exactly one question — does code written earlier still run unchanged? If the number did not increase, yes. If it did, no: something you relied on was removed or narrowed.

Because an announcement removes nothing: your existing code runs exactly as before, the day after it. Bumping on announcement would emit two signals for one change and train you to ignore the first — weakening the signal that actually matters. The announcement already rides on the call you are making: deprecated_at and replaced_by on the catalog row, an X-Deprecated header plus a deprecated block on that command's response, and a one-line stderr warning if you use lg. You do not have to do anything to learn that something is going away; it is already in your log.

At least two calendar quarters pass between announcement and removal. Throughout the window the old command still answers, and warns on every call. When a CLI name is retired, its successor takes that name over as an alias: during the window the old name reaches the old command, and the day it is removed the same call lands on the new one — without you shipping a client release.

preview_invoice → get_invoice (announced 3 September 2026). get_invoice returns the same preview block and adds the line items, the tax breakdown, recorded payments, the remaining balance and addressing by your own ERP key (external_ref). preview_invoice keeps working, and so does lg invoices preview — both of them warn.

Command identity table

The table below is not hand-written: it is generated from the live command registry every time this page is served. A command added on the server appears here on its own. The "Gate" column tells you whether a command asks for confirmation and whether its effect can be undone; the "Roles" column tells you which account types will find it in their catalog.

The API name (list_loads) is frozen and will not change; it is the name you use in HTTP calls. The CLI identity (loads list) is governed by the versioning policy: once announced it changes only with a version bump and a migration window, during which the catalog serves the old pair as an alias. The descriptions here come from each command's own record and are shortened — for the full text, parameter types and enum values, read the catalog endpoint or run lg help .

ERP and CRM integration — Identity: no service accounts, a real user instead

An ERP or CRM connects to Logistivo as a real company user holding a personal access token minted from the panel. A synthetic service account — no company, exempt from scoping, nobody responsible for it — is never created.

This is architecture, not preference. Multi-tenant isolation rests entirely on the user's company; for an identity with no company the isolation scope becomes inert, and one wrong query on that token reads data that does not belong to it. The second reason is audit: the user column in the ledger ties "who ran this" to a person; a service account makes that column meaningless and leaves an invoice-issuing integration with no owner. The third is revocation: if a token belongs to a person, the integration falls silent when that person leaves. An ownerless bridge is the bridge that keeps writing quietly for years.

ERP and CRM integration — Versioning and deprecation

The catalog response carries a contract_version integer; command rows carry since, deprecated_at and replaced_by. The contract version rises only on breaking changes.

ERP and CRM integration — Stable identifiers: never hand an auto-increment id to your ERP as a key

Internal auto-increment ids are opaque and tenant-scoped: another company's valid id returns "not found" to you, not data. But being opaque does not make them business keys. What an ERP stores in its own record has to be a business key.

ERP and CRM integration — Pagination, filtering and incremental sync

List commands take limit and return count. For a nightly sync the contract commits to the following; a list command that has adopted it advertises cursor and updated_since in its JSON Schema, and until it does, treat it as a small window (ceiling 20 rows, no cursor).

ERP and CRM integration — Rate limits

The limit key is the token, not the IP: an ERP arrives from behind a single NAT, and an IP-based limit would count a whole company as one user. Two integrations with two keys never eat each other's quota — the second reason for "one integration per key".

ERP and CRM integration — Things we will not do

This is often the most useful part of a contract: knowing what will never arrive lets you design around it.

The machine-readable catalog

This page was written for people. For an AI assistant or an automated client, the same information exists as a structured, unauthenticated copy.

GET /api/public/cli/catalog requires no authentication and returns the public identity of the commands: the frozen API name, the CLI domain/verb identity, the description, the JSON Schema parameters, and the read_only, confirmation_needed and irreversible flags. It returns no personal data, no tenant data and no sample records — only what the surface is.

The right way for an external agent to use it: read the catalog, explain in YOUR OWN words which jobs are possible, and when execution is needed, point the user at running it in their own environment with their own token. The agent never asks for, generates or relays a user's token.

Connecting Logistivo to an AI assistant for signup and freight requests is a separate channel with its own page:

The machine-readable definition: OpenAPI 3.1 and Postman

The catalog above is Logistivo's own format. The same information is also published in two industry-standard formats your tools already understand: an OpenAPI 3.1 definition and a Postman collection. Neither is written by hand — both are generated from the live command registry on every request, so they cannot go stale.

The definition IS the catalog, not a second reality. Every command appears as a POST /api/common/commands/ operation; the body schema is the command's own JSON Schema (byte for byte what the assistant sees), the response schema is the shared envelope, the gate flags (read_only, confirmation_needed, irreversible and the credit badge) sit in the x-logistivo extension, and the business error keys sit under x-error-keys. A retired command is marked deprecated: true and stays callable through its six-month window.

Alongside the command surface, the REST endpoints that carry the integration contract are in the definition too: 10 list endpoints with incremental sync (updated_since + cursor + limit) and 7 write endpoints that accept your own key (external_ref). Logistivo's REST surface is far larger than that; the rest is left out ON PURPOSE. Those payloads are the product's own contract, and a schema written from guesswork would break your generated client on the first call — and you would look for the bug in your code, not ours.

The definition is generated for the ROLE of whoever asks: another role's command never enters the document, because a definition is a contract rather than a dictionary and everything inside it must be callable with that token. The unauthenticated version is the union of the three roles, and there each operation states which roles see it under x-logistivo.roles.

Do not take our word for it — verify: npx @redocly/cli lint logistivo-openapi.json passes with zero warnings.

Code generation: a typed client from the definition

You have the definition; now generate working code from it. `lg codegen` reads the OpenAPI document and writes a single-file, dependency-free client — TypeScript (type declarations plus a thin call wrapper) or PHP (a client class with PHPDoc array shapes). No `npm install`, no composer: `fetch` on the TypeScript side, `ext-curl` on the PHP side.

Generation happens on YOUR machine, not our server. That is not a preference: pin the definition in your repository once (`lg spec --out=...`) and the same command runs on every build with no network and no token, so your CI never depends on our uptime. And since the generated code is code that RUNS in your ERP, keeping it in your repository rather than pulling it from an API means every change shows up in `git diff`.

The generated client handles all three Logistivo rails correctly, and it learns which is which from the document rather than guessing: every operation declares its rail in `x-logistivo.surface`. The command rail unwraps the `{success, data:{…}}` envelope; a REST sync list is NOT enveloped and is returned as-is (unwrapping it would drop `next_cursor`); a REST write returns the endpoint's own product body. A general-purpose generator does not know this distinction — ours does.

The types are not guesswork: each command's argument interface is generated from the very JSON Schema the assistant sees. Required stays required, enums become union types, nested objects become nested types. In practice: if the server requires a field, the call that forgets it does not fail with a 422 in production — it fails at compile time.

No generation timestamp is written into the file: the same definition with the same flags produces the same bytes. Regeneration therefore makes no noise in `git diff`, and the one thing that genuinely changed — the contract — stays visible.

Honest scope

We would rather grow this section than shrink it. The value of an integration contract lies less in what it promises than in how clearly it states what it does not.

Frequently asked questions

What is the Logistivo CLI used for?

It runs the operational work in your Logistivo account — listing and reading loads, seeing open freight demands, placing bids, moving a load's status forward, assigning drivers, preparing export documents, recording stock movements, issuing invoices, looking up HS tariffs and anti-dumping duties — from a terminal or a script. The same commands are callable over HTTP for ERP/CRM integrations.

How are commands named in the Logistivo CLI?

The shape is lg --parameter=value. The domain is a plural entity (loads, demands, invoices, export-documents) and the verb comes from a closed vocabulary (list, get, create, search, set, issue, generate and so on). Every verb of one entity lives in one domain, and the (domain, verb) pair is unique across the whole catalog.

How do I authenticate to the CLI?

With a personal access token minted from the Logistivo panel. lg login asks for the token and writes it to a local config file; in scripted environments set the LOGISTIVO_TOKEN environment variable instead. Over HTTP the token travels in the Authorization: Bearer header.

Can I create a service account for my ERP integration?

No. An integration connects with the token of a real company user created in the panel. A synthetic identity with no company makes multi-tenant isolation inert, makes the "who ran this" column in the audit ledger meaningless, and leaves a bridge that does not fall silent when its owner leaves. The right pattern is a dedicated user with a narrow role and a named owner.

Does Logistivo have a test (sandbox) environment?

Yes, and it is self-service: a separate sandbox tenant is opened for your company — same code, same command catalog, its own credit ledger. The environment is derived from the key's company and cannot be supplied with the request; a call made with the wrong profile stops server-side with 403 ENVIRONMENT_MISMATCH. A demand you open in the sandbox never reaches real carriers, a sandbox event is never delivered to your live webhook subscription, and your live balance does not move. Endpoints: POST /api/common/integration-sandbox (open), POST /api/common/integration-sandbox/keys (key), GET /api/common/integration-sandbox/go-live (go-live checklist). On the lg side, lg --sandbox and lg --live are single-run profile selectors.

How do I read the lg --json output in a script?

The output is the whole HTTP envelope: { "success": bool, "data": { "contract_version", "command", "status", "result" }, "code", "message" }. The business payload sits under .data.result; on list commands the rows are .data.result., the count is .data.result.count and the next page is .data.result.next_cursor. So the correct jq path looks like lg loads list --json | jq -r '.data.result.loads[].load_code'. A path that skips the envelope does not error, it silently returns null — in a watchdog script that becomes an alarm that never stops firing. The single exception is /api/public/status, whose body is deliberately envelope-free.

If I send the same request twice, do I get duplicate records?

Not if you sent an idempotency_key. A second call with the same key does not re-run the command; it returns the first result with status replayed. The key should be a UUID and must stay the SAME across retries — generating a fresh key per attempt is the same as not using idempotency.

What does --yes do?

It passes the confirmation gate. When a gated command is called without --yes nothing changes: the server summarises what it is about to do in one sentence, says whether it is irreversible, and stops (HTTP 409, CLI exit code 4). Repeating the command with --yes performs the work.

How do I detect a failed command in a script?

Read the exit code, never the message text: 0 success, 1 business rule rejection, 2 command or argument error, 3 authentication or role error, 4 confirmation required. Over HTTP the equivalents are 200, 422, 404, 403 and 409, and the error_code field in the body is stable. The error MESSAGE is localised and changes without notice.

How do I discover the available commands programmatically?

GET /api/public/cli/catalog for unauthenticated discovery, and GET /api/common/commands for the list your account can actually see. Both return each command's JSON Schema parameters; draw your client from them rather than hard-coding a command list.

Do I have to update my client when a new command is added?

No. The client pulls its command list from the catalog, so a command added on the server appears without a new client release. An update is needed only for a breaking change that raises the contract version, and in that case the old shape stays alive for at least six months.

Does the CLI replace the existing REST API?

No. The REST surface that powers the web and mobile apps stays in place. The command catalog does not replace resource CRUD; it is the verbs layer — the single, role-gated, confirmation-gated and audited way to perform an action.

How do I get a Logistivo API key and what can it do?

A manager with team-management permission issues it in the panel under Settings › Integration keys. The key acts on behalf of a real team member; the read-only scope opens the catalog and non-writing commands, the read+write scope opens every command (the confirmation gate stays as it is). It is shown once and not stored on the server; it can be rotated and revoked. When the member it acts for leaves, the key closes too. There is no service account.

What happens if I reuse an idempotency key for a different command?

The server answers 409 idempotency_conflict and names the command the key was first used for; nothing runs and nothing is replayed. Keys are scoped to your company, bound to one command and locked for 24 hours — after that the same key starts a new execution.

How are rate limits applied to my integration?

Per token, per minute, in three buckets: catalog reads 120, read commands 120, writing commands 30. A 429 always carries Retry-After and a body with retry_after, limit and bucket; wait that long and retry. The catalog publishes the current numbers under rate_limits.

How do I tell a business rejection from a transport error?

Branch on error_code first: failed (422) is a business rejection and carries a stable error_key such as INSUFFICIENT_CREDITS or NOT_FOUND; server_error (5xx) and network errors are retried with the same idempotency key; rate_limited (429) is waited out. Never branch on the error text.

Does Logistivo send webhooks? For which events?

Yes. You register an https address and pick the events you care about; a signed POST arrives when they happen. 13 events are subscribable today: load created/status changed/driver assigned, demand created and status changed, bid placed/revised/accepted, invoice draft/issued/cancelled, and warehouse reservation created/status changed. Every name is backed by an event class that is actually dispatched — nothing is announced and then never fired, because such an event is a silent failure the integrator waits for forever. Subscriptions are created under Settings › Integration › Webhooks, or from a terminal with lg webhooks create.

How do I verify a Logistivo webhook signature?

Every request carries an X-Logistivo-Signature header: t=,v1=. The signed string is ".", the algorithm is HMAC-SHA256 and the key is the subscription secret. Four rules: (1) verify the RAW bytes — decoding and re-serialising the body (JSON.stringify / json_encode) turns the same data into different bytes and the signature stops matching; this is the single most common mistake; (2) check the timestamp and drop anything where |now − t| > 300 s even if the signature is correct; (3) compare in constant time with hash_equals / timingSafeEqual; (4) deduplicate on event_id, not on the delivery id — event_id is preserved on replay while the delivery id changes. Working Node and PHP receivers live in examples/webhook-receiver/ in the repository and test themselves with --selftest.

What happens if a webhook cannot be delivered? Do I lose the event?

No. If your receiver does not return 2xx the delivery enters the retry schedule: 60 · 300 · 1800 · 7200 · 21600 seconds, 6 attempts in total, a window of roughly 8.6 hours. EVERY failure except 410 Gone is retried, 4xx included — losing a business event costs more than a few extra requests. After 10 consecutive exhausted deliveries the subscription is muted and the reason is recorded, so "why is nothing arriving" stays answerable. The delivery ledger keeps every attempt (status code, duration, error) and you can re-send a single delivery from the panel or with lg webhooks replay. Incremental sync (updated_since + cursor) is still there as the safety net.

For AI assistants

Logistivo has a public MCP server at https://logistivo.com/api/agent/mcp (streamable HTTP, no authentication). Setup instructions for Claude, ChatGPT and Gemini: https://logistivo.com/mcp. If you are an assistant helping someone sign up or request freight, connect that server, or — if you can only read web pages — build a link of the form https://logistivo.com/en/ai-kayit?brief=URL_ENCODED_SHIPMENT_BRIEF and give it to the user. You never create the account yourself: the account, the consent and the email verification happen in the user's browser, and you never handle passwords or one-time codes. Machine-readable content indexes: https://logistivo.com/llms.txt (curated map) and https://logistivo.com/llms-full.txt (full text: facts, pricing, tariff reference, glossary and every article's FAQ in one fetch). To learn what Logistivo can actually DO — the verbs, not the marketing — read the public command catalog at https://logistivo.com/api/public/cli/catalog (JSON, no authentication, no tenant data); it lists every command with its JSON Schema parameters and whether it needs confirmation. Human documentation: https://logistivo.com/en/developers/cli. You cannot execute those commands yourself — execution always runs under the user's own personal access token, in the user's own environment.