# Tendered — integration skill

Teach this file to your AI agent and it can build your side of a Tendered
integration: your TMS, ERP or EDI flow speaking the shared contract, tested
against the dry-run door before any real data flows.

Tendered is the tender desk for European road freight — it reads a shipper's
RFQ workbook, prices every lane, and returns the original file filled. An
integration feeds it what your systems already know (executed shipments,
costs, references) and receives tender results back.

Base URL: https://www.tendered.today

## The contract — one shape every system lands in

Every inbound row is one executed shipment. Fields, all optional except the
lane and the date:

| Field | Meaning |
| --- | --- |
| `origin`, `destination` | The lane's ends — city and postcode, country if you have it ("Lille 59000, FR"). Required. |
| `date` | The day it ran, ISO `YYYY-MM-DD` (day-first European dates are read too). Required. |
| `shipperRateEur` | What the shipper was charged, in euros. This is the number price history learns from. |
| `carrierCostEur` | What the carrier was paid. Cost stays on the cost side and stays out of price history. |
| `equipment` | Tautliner, frigo, mega… |
| `customer` | The shipper the movement ran for. |
| `forwarderCustomer` | Set when the row ran as a subcontract and another forwarder is the customer. |
| `branch`, `loads`, `km`, `currency` | Branch or depot · loads on the row (default 1) · distance · ISO currency code. |
| `sourceReference` | Your system's own id for the row. Send it on every row — it is the row's identity across sends. |
| `reference`, `customerReference` | Your order number · the customer's own reference. |
| `vehicle`, `trailer`, `driver`, `carrier` | The truck, the trailer plate, the driver, the subcontractor. |
| `loadFacility`, `loadAddress`, `plannedLoadAt`, `loadedAt` | The loading side: site, address, the planned hour, the actual hour. |
| `unloadFacility`, `unloadAddress`, `plannedUnloadAt`, `unloadedAt` | The unloading side, same four. |

### The rules that hold on every row

- **A stated rate is always scoped: it sits on a lane (exact loading and unloading, per trip) or on a corridor (zone to zone at the two-digit postcode level, a country on both ends, per km). The basis follows the precision.** A shipment row carries its own lane, so the scope
  travels with the number. A number detached from a lane or corridor has no
  home in Tendered and is refused everywhere.
- **A cost and a price are two fields.** `carrierCostEur` is what you paid;
  `shipperRateEur` is what you billed. Only the billed side reaches price
  history. A row carrying a cost alone is held back and counted, with the
  reason said in words.
- **Only what is known travels.** Leave absent fields out or null. Tendered
  fills no gap with a plausible value, converts no currency, and reports
  every held-back or unreadable row by position with its reason.
- **Non-EUR rows are held back** with the currency named — executed history
  is in euros, and asserting an exchange rate would invent the number.

## The doors

1. **API pull** — Tendered reads your endpoint on the schedule set on the
   connection (manual · daily · weekly): an https address answering rows as
   JSON, the key stored encrypted on the connection. Rows may come as a bare
   array or under a key like `data` or `rows`.
2. **Webhook push** — your system POSTs batches to the connection's own door,
   `POST /api/integrations/hook/{token}`, on your clock. JSON only, up to 5 MB and 5000
   rows per delivery, 60 deliveries per hour per connection.
3. **Files and e-mail** — exports land as CSV through upload or the
   workspace's intake address. **EDI arrives as files too**: an EDIFACT flow
   (IFTMIN, IFTSTA, INVOIC) lands through the same file door as a mapped
   export, and needs no new infrastructure on your side.

### Auth

Each connection's door is its own unguessable token (`ih_…` in the URL for
the webhook), minted inside Tendered, shown once at mint time and stored
hashed. Treat it as a secret; a leaked token is revoked by re-minting the
connection's door. An unknown token answers 404 with nothing else said.

## The dry-run loop — build against the door before real data

`POST /api/integrations/hook/{token}/validate` reads a sample payload through the exact same
reading as a real delivery — token, caps, field map, contract — and stores
nothing. The loop your agent should run:

1. Post a small sample batch to the validate door.
2. Read the answer: `rowsRead`, `wouldLand`, `fieldsRecognised`,
   `columnsUnread`, and `skipped` with a reason per row. The answer holds
   counts, field names and reasons — your values stay yours.
3. Fix the mapping or the export where a column went unread or a row was
   skipped.
4. Repeat until the whole batch would land, then point the real sends at
   `POST /api/integrations/hook/{token}`.

## Mirroring — what your side should do so upstream changes reach here

- **Send `sourceReference` on every row.** It is the row's identity across
  sends and what lets a correction find its shipment.
- **Re-sends are safe.** Delivering the same rows again changes nothing —
  every way in is deduplicated, so a retry after a timeout can never
  double-count.
- **Corrections mirror.** When your system corrects a shipment, re-send the
  full corrected row under the same `sourceReference` — the mirror updates
  that shipment and keeps its history.
- **Cancellations withdraw.** A cancelled shipment is re-sent marked
  cancelled under its `sourceReference` — the mirrored row is withdrawn and
  stays on the record with its state said.
- **Late data is normal.** Rows carry their own dates, so a shipment arriving
  a month late lands in the right month.
- **Every mirror action is a sync-log line** on the connection — each run,
  each result, each error in words.

## Field-name hints — where common ERP and TMS columns land

Column names are mapped once per connection and remembered. These are the
usual suspects (CargoWise, SAP, Dynamics and the smaller TMS exports):

| Their column says | It lands as |
| --- | --- |
| From · Loading place · Pickup · Abgangsort | `origin` |
| To · Delivery city · Dropoff · Empfangsort | `destination` |
| Load date · Shipment date · Datum · Execution date | `date` |
| Sell rate · Revenue · Invoiced · Verkaufspreis | `shipperRateEur` |
| Buy rate · Carrier cost · Unit_Cost · Einkaufspreis | `carrierCostEur` |
| Rate · Price · Preis · Amount · Freight | `a money value — a person confirms which side it is` |
| Client · Shipper · Account · Kunde | `customer` |
| Order number · Consignment · Auftragsnummer | `reference` |
| ID · Record id · UUID | `sourceReference` |
| Trailer type · Vehicle type · Body type | `equipment` |
| Distance · Mileage · KM | `km` |
| Currency · CCY · Währung | `currency` |

A money column that names no side (Rate, Price, Preis, Amount) waits for a
person to say whether it is the billed side or the paid side — a wrong side
would teach the pricer the wrong number.

## Sample payloads — fictional companies, each rate on its own named lane

Webhook batch:

```json
[
  {
    "origin": "Lille 59000, FR",
    "destination": "Essen 45127, DE",
    "equipment": "Tautliner",
    "date": "2026-07-14",
    "shipperRateEur": 980,
    "customer": "Nordwind Spedition",
    "km": 320,
    "sourceReference": "SHP-104233"
  },
  {
    "origin": "Gent 9000, BE",
    "destination": "Lyon 69007, FR",
    "equipment": "Frigo",
    "date": "2026-07-15",
    "carrierCostEur": 1140,
    "customer": "Bellamare Foods",
    "km": 780,
    "sourceReference": "SHP-104234"
  }
]
```

Dry-run answer for that batch:

```json
{
  "ok": true,
  "dryRun": true,
  "line": "2 rows read · 1 would land as executed shipments · 1 would be held back. Dry run — the workspace stays as it was.",
  "rowsRead": 2,
  "wouldLand": 1,
  "duplicatesInBatch": 0,
  "fieldsRecognised": [
    "carrierCostEur",
    "customer",
    "date",
    "destination",
    "equipment",
    "km",
    "origin",
    "shipperRateEur",
    "sourceReference"
  ],
  "columnsUnread": [],
  "skipped": [
    {
      "row": 2,
      "reason": "Carries what the carrier was paid. Executed history holds what the shipper was charged."
    }
  ]
}
```

## How results come back

Connections with outbound enabled receive tender results as JSON POSTs to
your endpoint: when a round closes or an award is accepted, the tender's
lanes, the awarded companies, their splits and agreed volumes — the sender's
own data only. Deliveries retry with backoff and every attempt is a sync-log
line.

## Working a workspace directly

Your AI agent can also work a Tendered workspace over the Model Context
Protocol: `POST /api/mcp` with a bearer token minted inside
Tendered (Setup → Agent access). Reads by default; write scope is granted
per token. Sending a filled tender to a customer stays a human click.

## Where to start

- Developer corner: https://www.tendered.today/connect/systems
- This file: https://www.tendered.today/connect/tendered-integration-skill.md
- Connections are minted inside Tendered, in Setup.
