# Lyverto API

> Connect your own systems to Lyverto: send consignments and shipment updates in, receive every event out. Values in angle brackets (`<uuid>`) are placeholders in example responses.

## Overview

The Lyverto API connects the systems you already run — a TMS, a warehouse spreadsheet, a carrier feed, Zapier — to Lyverto. Lyverto does not replace them. It carries data **in** (consignments and shipment updates, in your own format if you like) and **out** (to your customers’ tracking pages, email and WhatsApp, to your partners, and back to your tools as events).

|   |   |
| --- | --- |
| Base URL | `https://lyverto.com/api/v1` |
| Format | JSON over HTTPS. Times are ISO 8601 in UTC. |
| Authentication | `Authorization: Bearer <API key>` |
| Machine-readable | [OpenAPI 3.1](https://lyverto.com/api/v1/openapi.json) · [This page as Markdown](https://lyverto.com/developers/docs.md) |

### What you can build

- **Sync consignments** from your TMS or spreadsheet, and keep them in step as they change.
- **Push shipment updates** — one consignment or a whole container — with the reason and next step customers plan around.
- **Send your own JSON** through a mapping you build once, without changing your system.
- **Feed carrier events** in the DCSA Track & Trace format.
- **React to events** by webhook or by polling the event stream.
- **Power your own website** with consignment status and ready-made customer tracking links.
- **Automate without code** in Zapier, Make, n8n or Power Automate.

## Quickstart

1. In Lyverto, open **Settings → Integrations → API keys** and create a key with the `tracking:write` and `tracking:read` scopes. Copy it — it is shown once.
2. Check the key:

**curl**

```bash
curl https://lyverto.com/api/v1/me \
  -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": {
    "organizationId": "<uuid>",
    "scopes": ["tracking:write", "tracking:read"],
    "keyId": "<uuid>"
  }
}
```

1. Create your first consignment:

**curl**

```bash
curl -X POST https://lyverto.com/api/v1/consignments \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reference": "LC-0142",
    "customerName": "Ama Mensah",
    "customerPhone": "+233 24 123 4567",
    "consolidationRef": "MSKU1234567",
    "destination": "Tema",
    "publish": true
  }'
```

**JavaScript**

```js
const res = await fetch("https://lyverto.com/api/v1/consignments", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LYVERTO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    reference: "LC-0142",
    customerName: "Ama Mensah",
    customerPhone: "+233 24 123 4567",
    consolidationRef: "MSKU1234567",
    destination: "Tema",
    publish: true,
  }),
});
const { data, created } = await res.json();
```

**Python**

```python
import os, requests

res = requests.post(
    "https://lyverto.com/api/v1/consignments",
    headers={"Authorization": f"Bearer {os.environ['LYVERTO_API_KEY']}"},
    json={
        "reference": "LC-0142",
        "customerName": "Ama Mensah",
        "customerPhone": "+233 24 123 4567",
        "consolidationRef": "MSKU1234567",
        "destination": "Tema",
        "publish": True,
    },
)
consignment = res.json()["data"]
```

The customer now has a live tracking page (`trackingUrl` in the response) and, because you published it, has been notified.

## Authentication

Every request except `GET /openapi.json` needs an API key, sent as a bearer token (or in an `X-Api-Key` header). Keys start with `lyv_` and act for one business — nothing in a request body can name another.

| Scope | Allows |
| --- | --- |
| `tracking:write` | Create and update consignments, submit updates, run mappings, send carrier feeds |
| `tracking:read` | Read consignments, their timelines and your mappings |
| `events:read` | Read the event stream |
| `webhooks:manage` | Subscribe and unsubscribe webhooks (used by Zapier and Make instant triggers) |

> **Important:** Keep keys server-side. Never put one in a browser, a mobile app or a public repository. If a key leaks, revoke it in Settings → Integrations; revoking also removes every webhook it subscribed.

## Conventions

### Responses

Successful responses wrap the result in `data`. Errors carry a human-readable `error` and a stable machine-readable `reason`.

**Success**

```json
{ "success": true, "data": { … } }
```

**Error**

```json
{ "error": "This key lacks the tracking:write scope", "reason": "scope_required" }
```

### Request IDs

Every response has a `Request-Id` header (`req_…`). It is recorded in your request log; quote it when you contact support.

### Batches and per-item results

Endpoints that take many items (`/events`, `/ingest/{mapping}`, `/feeds/dcsa`) answer one result per item, in order. One bad item never fails the batch: it comes back `skipped` with a `reason`, and the rest are written.

**Per-item results**

```json
{
  "success": true,
  "data": [
    { "index": 0, "status": "proposed", "proposalId": "<uuid>" },
    { "index": 1, "status": "duplicate", "id": "<uuid>" },
    { "index": 2, "status": "skipped", "reason": "no consignment or container of yours matches the target" }
  ]
}
```

| Status | Meaning |
| --- | --- |
| `created` / `updated` | A consignment was written |
| `proposed` | The update is in your Review queue, waiting for a person (or an automation) to approve it |
| `appended` | The update was written to the timeline directly |
| `duplicate` | This `externalEventId` was already received; nothing was written |
| `skipped` | The item could not be used; `reason` says why |

### Safe retries

Give every update an `externalEventId` (your own id for it). Sending the same id again is answered `duplicate`, so a retry after a timeout never records an event twice. Consignments are keyed by `reference`, so re-sending one updates it instead of creating a copy.

### Dates

Send ISO 8601 (`2026-10-20T08:00:00Z`). Through a mapping, `20/10/2026` (day first) and Unix seconds or milliseconds are also understood.

## Plans and rate limits

Limits count per business, across all of its keys. The free plan is meant for real use: one system or a couple of zaps, every day.

| Plan | Requests / minute | Requests / day | API keys | Webhooks | Mappings | Items per batch |
| --- | --- | --- | --- | --- | --- | --- |
| Free | 60 | 2,000 | 2 | 2 | 3 | 50 |
| Starter | 300 | 25,000 | 5 | 10 | 15 | 200 |
| Professional | 1,200 | 250,000 | 25 | 50 | 100 | 500 |
| Enterprise | 3,000 | 2,000,000 | 100 | 200 | 500 | 1,000 |

Every response tells you where you stand:

**Headers**

```http
Request-Id: req_4f0c2b9e7a1d3c5e8f6a2b1c
RateLimit-Limit: 60
RateLimit-Remaining: 57
RateLimit-Reset: 42
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1790640060
X-Quota-Limit: 2000
X-Quota-Remaining: 1873
X-Plan: free
```

Over a limit you get **429** with `Retry-After` (seconds) and a `reason`: `rate_limited` means slow down and retry; `quota_exceeded` means the day’s quota is used (it resets at 00:00 UTC).

**Response 429**

```json
{
  "error": "Too many requests: your plan allows 60 per minute. Retry in 18s.",
  "reason": "rate_limited",
  "requestId": "req_…"
}
```

**Handling it (JavaScript)**

```js
async function lyverto(path, init = {}, attempt = 0) {
  const res = await fetch(`https://lyverto.com/api/v1${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${process.env.LYVERTO_API_KEY}`, "Content-Type": "application/json", ...init.headers },
  });
  if (res.status === 429 && attempt < 3) {
    const body = await res.json();
    if (body.reason === "quota_exceeded") throw new Error(body.error);
    const wait = Number(res.headers.get("Retry-After") ?? 5);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return lyverto(path, init, attempt + 1);
  }
  return res;
}
```

## Sync consignments from your system

**Use case:** your TMS or spreadsheet is where bookings start. Push each one to Lyverto so the customer gets a tracking page, and push changes as they happen.

`POST /consignments` creates a consignment, or updates the one with the same `reference`. Only the fields you send are changed — a sync that does not know the customer’s email never erases the one your team typed.

**curl**

```bash
curl -X POST https://lyverto.com/api/v1/consignments \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reference": "LC-0142", "eta": "2026-10-24T00:00:00Z" }'
```

**Response 200 (updated)**

```json
{
  "success": true,
  "created": false,
  "data": {
    "id": "<uuid>",
    "reference": "LC-0142",
    "status": "in_transit",
    "description": "Mobile phones, 3 cartons",
    "customerName": "Ama Mensah",
    "customerEmail": "ama@example.com",
    "customerPhone": "233241234567",
    "consolidationRef": "MSKU1234567",
    "origin": "Guangzhou",
    "destination": "Tema",
    "eta": "2026-10-20T00:00:00.000Z",
    "trackingUrl": "https://lyverto.com/track/<token>",
    "customFields": { "cbm": "0.42" },
    "publishedAt": "2026-09-29T08:00:00.000Z",
    "createdAt": "2026-09-28T14:02:11.000Z",
    "updatedAt": "2026-09-29T08:00:00.000Z"
  }
}
```

### Moving a consignment along

Change the status with `PATCH /consignments/{reference}`. Moving a draft to `published`, `in_transit` or `delivered` publishes it first, and the customer is told once. A published consignment cannot go back to `draft` — its customer has already seen it.

**curl**

```bash
curl -X PATCH https://lyverto.com/api/v1/consignments/LC-0142 \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "delivered" }'
```

## Push shipment updates

**Use case:** the container departed, a consignment is held at customs, the ETA moved. Tell Lyverto once and every affected customer can see it — with the reason and the next step, which is what they plan their cash, trucks and stock around.

**One consignment**

```bash
curl -X POST https://lyverto.com/api/v1/events \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [{
      "target": { "reference": "LC-0142" },
      "externalEventId": "tms-88121",
      "label": "Customs hold",
      "code": "exception",
      "reason": "Physical inspection requested on HS 8517",
      "nextStep": "Clearance expected in 3–5 working days. No action needed from you.",
      "location": "Tema",
      "eta": "2026-10-24T00:00:00Z"
    }]
  }'
```

**A whole container**

```bash
curl -X POST https://lyverto.com/api/v1/events \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "append",
    "events": [{
      "target": { "consolidationRef": "MSKU1234567" },
      "externalEventId": "vsl-dep-2026-09-29",
      "label": "Departed Shanghai",
      "code": "in_transit",
      "nextStep": "Arrival at Tema expected 20 October"
    }]
  }'
```

**Response 200**

```json
{
  "success": true,
  "data": [{ "index": 0, "status": "proposed", "proposalId": "<uuid>" }]
}
```

### Review or write directly

By default (`"mode": "propose"`) updates wait in your **Review** queue until a person approves them — or an automation you set up does. Use `"mode": "append"` when the update comes from your own system and should go straight onto the timeline.

| `code` | Use for |
| --- | --- |
| `picked_up` | Received at origin or collected |
| `in_transit` | Moving: loaded, departed, arrived, discharged |
| `customs_clearance` | Customs events and releases |
| `out_for_delivery` | On the last leg |
| `delivered` | Handed over |
| `exception` | Holds, damage, delays |

## Send your own JSON with a mapping

**Use case:** your system already produces JSON in its own shape, and changing it is not an option. Build a mapping once, point your system at its URL, and send the JSON as it is.

1. In **Settings → Integrations → Data mappings**, choose **New mapping**, paste one sample of what your system sends, and choose **Read sample**.
2. For each Lyverto field, pick which of your fields fills it — or a fixed value, or a combination such as `{{status}} at {{port}}`. Translate your own codes with a lookup (`DEP=in_transit`).
3. **Preview**, then **Save**. You get a URL like `https://lyverto.com/api/v1/ingest/warehouse-sheet`.

**Your system sends**

```bash
curl -X POST https://lyverto.com/api/v1/ingest/warehouse-sheet \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "Ref No": "LC-0142", "Client": "Ama Mensah", "Container": "msku1234567", "ETA": "20/10/2026" },
      { "Ref No": "", "Client": "Unknown" }
    ]
  }'
```

**Response 200**

```json
{
  "success": true,
  "data": [
    { "index": 0, "status": "created", "reference": "LC-0142", "trackingId": "<uuid>" },
    { "index": 1, "status": "skipped", "reason": "Reference is empty." }
  ]
}
```

Add `?dryRun=true` to see what each item would become without writing anything — useful while you wire up a new source.

**Dry-run response**

```json
{
  "success": true,
  "dryRun": true,
  "data": [
    { "index": 0, "ok": true, "value": { "reference": "LC-0142", "customerName": "Ama Mensah", "consolidationRef": "MSKU1234567", "eta": "2026-10-20T00:00:00.000Z" } },
    { "index": 1, "ok": false, "errors": ["Reference is empty."] }
  ]
}
```

## Feed carrier events (DCSA)

**Use case:** you receive container events from a shipping line or a tracking aggregator. Forward them in the DCSA Track & Trace format; Lyverto matches each to your container by `equipmentReference` (or a document reference) and proposes a customer-friendly update.

**curl**

```bash
curl -X POST https://lyverto.com/api/v1/feeds/dcsa \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{
    "eventID": "7c1e…",
    "eventType": "EQUIPMENT",
    "eventClassifierCode": "ACT",
    "equipmentEventTypeCode": "DISC",
    "eventDateTime": "2026-10-19T22:10:00Z",
    "equipmentReference": "MSKU1234567",
    "eventLocation": { "locationName": "Tema", "UNLocationCode": "GHTEM" }
  }]'
```

**Response 200**

```json
{
  "success": true,
  "data": [{ "index": 0, "status": "proposed", "proposalId": "<uuid>" }]
}
```

That event becomes the proposal “Discharged from vessel at Tema (GHTEM)”. Estimated vessel arrivals (`EST` + `ARRI`) become ETA-change proposals. To skip review for routine events, turn on the “Approve routine carrier events automatically” automation in Lyverto.

## React to events: webhooks and polling

**Use case:** your system should know when something happens in Lyverto — a customer was told, an update was approved, a customer sent a WhatsApp message.

### Webhooks (push)

Add an endpoint in **Settings → Integrations → Webhooks** (or subscribe with `POST /hooks`). Lyverto POSTs batches of events, in order, and resumes where it stopped if your endpoint is down. Delivery is at least once: respond `2xx` quickly and de-duplicate on `seq`.

**Delivery body**

```json
{
  "id": "<delivery-uuid>",
  "organizationId": "<uuid>",
  "sentAt": "2026-09-29T08:10:05.000Z",
  "events": [
    {
      "seq": 1042,
      "hash": "<sha256>",
      "action": "tracking.event.appended",
      "subjectType": "tracking_record",
      "subjectId": "<uuid>",
      "summary": "Added checkpoint \"Customs hold\" on consignment LC-0142",
      "payload": { "reference": "LC-0142", "label": "Customs hold", "reason": "Physical inspection requested", "nextStep": "Clearance in 3–5 days", "…": "…" },
      "actorKind": "human",
      "source": "api",
      "occurredAt": "2026-09-29T08:10:00.000Z"
    }
  ]
}
```

Verify every delivery. The `Lyverto-Signature` header is `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">` using your endpoint’s signing secret. Reject signatures older than five minutes.

**Node.js**

```js
import crypto from "node:crypto";

export function verifyLyverto(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
import hmac, hashlib, time

def verify_lyverto(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

### Polling (pull)

No public endpoint? Poll `GET /events` with the cursor from your last call. Store `cursor.after` and pass it back as `after`.

**curl**

```bash
curl "https://lyverto.com/api/v1/events?after=1041&limit=100" \
  -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": [ { "seq": 1042, "action": "tracking.event.appended", "…": "…" } ],
  "cursor": { "after": 1042 }
}
```

### Event types

| `action` | Happens when |
| --- | --- |
| `tracking.record.created` / `.updated` / `.published` | A consignment was created, edited, or made visible to its customer |
| `tracking.event.appended` | A timeline update was recorded (by a person, an agent, a feed or your API) |
| `tracking.broadcast.withdrawn` | A container update was taken back |
| `tracking.proposal.created` / `.approved` / `.rejected` | An update entered Review, or was decided |
| `tracking.share.created` / `.revoked` | A consignment was forwarded to a partner, or access withdrawn |
| `tracking.customer.notified` | An automation told a customer |
| `channel.message.received` | A customer sent a WhatsApp message |
| `tracking.rule.*`, `integration.*` | Automations, keys, webhooks, mappings or WhatsApp settings changed |

## Power your own website

**Use case:** keep your own site and portal, and use Lyverto as the backend for shipment status. Each consignment carries `trackingUrl` — the customer’s live tracking page — once published, so you can link to it, or read the status and timeline and show them in your own design.

**curl**

```bash
curl https://lyverto.com/api/v1/consignments/LC-0142 \
  -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": {
    "reference": "LC-0142",
    "status": "in_transit",
    "eta": "2026-10-20T00:00:00.000Z",
    "trackingUrl": "https://lyverto.com/track/<token>",
    "…": "…",
    "events": [
      {
        "id": "<uuid>",
        "kind": "checkpoint",
        "code": "exception",
        "label": "Customs hold",
        "reason": "Physical inspection requested on HS 8517",
        "nextStep": "Clearance expected in 3–5 working days",
        "occurredAt": "2026-10-19T09:00:00.000Z",
        "location": "Tema",
        "etaBefore": "2026-10-20T00:00:00.000Z",
        "etaAfter": "2026-10-24T00:00:00.000Z",
        "source": "manual",
        "supersedesEventId": null
      }
    ]
  }
}
```

> **Important:** Call the API from your server, never from the visitor’s browser — the key must stay secret. Cache responses for a minute or two to stay well inside your plan.

## Zapier, Make, n8n and Power Automate

**Use case:** connect Lyverto to Google Sheets, a CRM, email or accounting without writing code.

- **Zapier:** use the Lyverto app. Triggers: *New Event* (instant) and *New or Updated Consignment*. Actions: *Create or Update Consignment*, *Add Shipment Update*, *Send Data Through a Mapping*. Search: *Find Consignment*. Give its key the `webhooks:manage` scope for instant triggers.
- **Make, n8n, Power Automate, Postman:** import the OpenAPI description at `https://lyverto.com/api/v1/openapi.json`, or use an HTTP step with your key.
- **Instant triggers in your own tool:** subscribe a URL with `POST /hooks` and remove it with `DELETE /hooks/{id}`.

**Subscribe**

```bash
curl -X POST https://lyverto.com/api/v1/hooks \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/catch/123", "events": ["tracking.event.appended"] }'
```

**Response 201**

```json
{
  "success": true,
  "data": {
    "id": "<uuid>",
    "url": "https://hooks.example.com/catch/123",
    "events": ["tracking.event.appended"],
    "secret": "whsec_<shown once>"
  }
}
```

## Account

### `GET /me`

The business and scopes this key acts for. Use it as a connection test.

Scope: any key

**Request (curl)**

```bash
curl https://lyverto.com/api/v1/me -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": { "organizationId": "<uuid>", "scopes": ["tracking:read"], "keyId": "<uuid>" }
}
```

### `GET /usage`

Your plan, its limits, and today’s usage.

Scope: any key

**Request (curl)**

```bash
curl https://lyverto.com/api/v1/usage -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": {
    "plan": "free",
    "limits": { "label": "Free", "requestsPerMinute": 60, "requestsPerDay": 2000, "maxApiKeys": 2, "maxWebhooks": 2, "maxMappings": 3, "maxBatch": 50 },
    "today": 127,
    "thisMinute": 3,
    "counts": { "apiKeys": 1, "webhooks": 1, "mappings": 2 }
  }
}
```

## Consignments

### `GET /consignments`

Your consignments, most recently changed first. For polling, pass the time of your last poll as `updatedSince`.

Scope: `tracking:read`

**Query parameters**

| Name | Type | Description |
| --- | --- | --- |
| `status` | string | `draft`, `published`, `in_transit`, `delivered`, `on_hold` or `cancelled` |
| `consolidationRef` | string | Only this container or master bill |
| `updatedSince` | ISO date-time | Only consignments changed after this moment |
| `limit` | integer | 1–100, default 50 |

**Request (curl)**

```bash
curl "https://lyverto.com/api/v1/consignments?status=in_transit&limit=20" \
  -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": [
    {
      "id": "<uuid>",
      "reference": "LC-0142",
      "status": "in_transit",
      "description": "Mobile phones, 3 cartons",
      "customerName": "Ama Mensah",
      "customerEmail": "ama@example.com",
      "customerPhone": "233241234567",
      "consolidationRef": "MSKU1234567",
      "origin": "Guangzhou",
      "destination": "Tema",
      "eta": "2026-10-20T00:00:00.000Z",
      "trackingUrl": "https://lyverto.com/track/<token>",
      "customFields": { "cbm": "0.42" },
      "publishedAt": "2026-09-29T08:00:00.000Z",
      "createdAt": "2026-09-28T14:02:11.000Z",
      "updatedAt": "2026-09-29T08:00:00.000Z"
    }
  ]
}
```

### `POST /consignments`

Create a consignment, or update the one with this `reference`. Only the fields you send are changed.

Scope: `tracking:write`

**Body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reference` | string | yes | Your reference (max 100) |
| `description` | string |  | What the goods are |
| `customerName` | string |  |  |
| `customerEmail` | string |  | Where email updates go |
| `customerPhone` | string |  | WhatsApp number with country code, e.g. `+234 803 123 4567` |
| `consolidationRef` | string |  | Container or master bill number |
| `origin` | string |  |  |
| `destination` | string |  |  |
| `eta` | ISO date-time |  |  |
| `status` | string |  | See the lifecycle rules in the guide |
| `publish` | boolean |  | On creation: make it visible to the customer and notify them |

**Request (curl)**

```bash
curl -X POST https://lyverto.com/api/v1/consignments \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reference": "LC-0142", "customerName": "Ama Mensah", "publish": true }'
```

**Response 201 Created · 200 Updated**

```json
{
  "success": true,
  "created": true,
  "data": {
    "id": "<uuid>",
    "reference": "LC-0142",
    "status": "in_transit",
    "description": "Mobile phones, 3 cartons",
    "customerName": "Ama Mensah",
    "customerEmail": "ama@example.com",
    "customerPhone": "233241234567",
    "consolidationRef": "MSKU1234567",
    "origin": "Guangzhou",
    "destination": "Tema",
    "eta": "2026-10-20T00:00:00.000Z",
    "trackingUrl": "https://lyverto.com/track/<token>",
    "customFields": { "cbm": "0.42" },
    "publishedAt": "2026-09-29T08:00:00.000Z",
    "createdAt": "2026-09-28T14:02:11.000Z",
    "updatedAt": "2026-09-29T08:00:00.000Z"
  }
}
```

### `GET /consignments/{reference}`

One consignment, with its customer-visible timeline in `events`.

Scope: `tracking:read`

**Request (curl)**

```bash
curl https://lyverto.com/api/v1/consignments/LC-0142 -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": {
    "reference": "LC-0142",
    "…": "all consignment fields",
    "events": [ { "id": "<uuid>", "label": "Departed Shanghai", "…": "…" } ]
  }
}
```

- `404` with `reason: not_found` when you have no consignment with that reference.

### `PATCH /consignments/{reference}`

Update the fields you send. Same fields as `POST /consignments`, without `reference` and `publish`.

Scope: `tracking:write`

**Request (curl)**

```bash
curl -X PATCH https://lyverto.com/api/v1/consignments/LC-0142 \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "in_transit", "eta": "2026-10-24T00:00:00Z" }'
```

**Response 200**

```json
{ "success": true, "data": { "reference": "LC-0142", "status": "in_transit", "…": "…" } }
```

- `400` with `reason: validation_failed` when moving a published consignment back to `draft`.

### `GET /consignments/{reference}/events`

Just the customer-visible timeline, oldest first.

Scope: `tracking:read`

**Request (curl)**

```bash
curl https://lyverto.com/api/v1/consignments/LC-0142/events -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": [
    { "id": "<uuid>", "kind": "checkpoint", "code": "in_transit", "label": "Departed Shanghai", "reason": null, "nextStep": "Arrival at Tema expected 20 October", "occurredAt": "2026-09-29T06:00:00.000Z", "location": "Shanghai", "source": "partner", "…": "…" }
  ]
}
```

## Updates and the event stream

### `POST /events`

Submit timeline updates. Each goes to Review (`propose`, the default) or straight to the timeline (`append`).

Scope: `tracking:write`

**Body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | "propose" \| "append" |  | Default `propose` |
| `events[].target` | object | yes | Exactly one of `reference`, `consolidationRef` or `trackingId` |
| `events[].label` | string | yes | What happened (max 200) |
| `events[].externalEventId` | string |  | Your id — makes retries safe |
| `events[].code` | string |  | See the codes table in the guide |
| `events[].reason` | string |  | Why it happened — shown to the customer |
| `events[].nextStep` | string |  | What happens next — shown to the customer |
| `events[].occurredAt` | ISO date-time |  | Defaults to now |
| `events[].location` | string |  |  |
| `events[].latitude / longitude` | number |  | Both or neither |
| `events[].eta` | ISO date-time |  | A revised ETA |
| `events[].standardCode` | string |  | Industry code, e.g. `dcsa:DISC` |

**Request (curl)**

```bash
curl -X POST https://lyverto.com/api/v1/events \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "events": [{ "target": { "reference": "LC-0142" }, "externalEventId": "tms-1", "label": "Picked up", "code": "picked_up" }] }'
```

**Response 200**

```json
{ "success": true, "data": [{ "index": 0, "status": "proposed", "proposalId": "<uuid>" }] }
```

- `400` with `reason: batch_too_large` over your plan’s batch size.

### `GET /events`

Everything that happened in your business, in order, by cursor.

Scope: `events:read`

**Query parameters**

| Name | Type | Description |
| --- | --- | --- |
| `after` | integer | The `cursor.after` from your previous call (0 to start) |
| `limit` | integer | 1–500, default 100 |

**Request (curl)**

```bash
curl "https://lyverto.com/api/v1/events?after=0" -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{
  "success": true,
  "data": [ { "seq": 1, "hash": "<sha256>", "action": "tracking.record.created", "subjectType": "tracking_record", "subjectId": "<uuid>", "summary": "Created consignment LC-0142 as a draft", "payload": {}, "actorKind": "system", "source": "partner_api", "occurredAt": "<date-time>" } ],
  "cursor": { "after": 1 }
}
```

## Mappings

### `POST /ingest/{mapping}`

Send any JSON — an object, an array, or an object holding the array — through a saved mapping.

Scope: `tracking:write`

**Query parameters**

| Name | Type | Description |
| --- | --- | --- |
| `dryRun` | boolean | `true` to see the mapped items without writing |

**Request (curl)**

```bash
curl -X POST "https://lyverto.com/api/v1/ingest/warehouse-sheet?dryRun=true" \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rows": [{ "Ref No": "LC-0142", "Client": "Ama Mensah" }] }'
```

**Response 200**

```json
{ "success": true, "dryRun": true, "data": [{ "index": 0, "ok": true, "value": { "reference": "LC-0142", "customerName": "Ama Mensah" } }] }
```

- `404` when no mapping has that name; `409` with `reason: mapping_paused` when it is paused.

### `GET /mappings`

Your saved mappings.

Scope: `tracking:read`

**Request (curl)**

```bash
curl https://lyverto.com/api/v1/mappings -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 200**

```json
{ "success": true, "data": [{ "slug": "warehouse-sheet", "name": "Warehouse sheet", "target": "consignment", "mode": "propose", "enabled": true }] }
```

## Carrier feeds

### `POST /feeds/dcsa`

DCSA Track & Trace events (a bare array, or `{ "events": [...] }`). Each is matched to your container or consignment and proposed.

Scope: `tracking:write`

**Request (curl)**

```bash
curl -X POST https://lyverto.com/api/v1/feeds/dcsa \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[{ "eventID": "…", "eventType": "TRANSPORT", "eventClassifierCode": "ACT", "transportEventTypeCode": "DEPA", "eventDateTime": "2026-09-29T06:00:00Z", "equipmentReference": "MSKU1234567" }]'
```

**Response 200**

```json
{ "success": true, "data": [{ "index": 0, "status": "proposed", "proposalId": "<uuid>" }] }
```

- Unmatched or non-customer-facing events come back `skipped` with the reason.

## Webhook subscriptions

### `POST /hooks`

Subscribe a URL to events. Omit `events` for everything. The signing secret is returned once.

Scope: `webhooks:manage`

**Body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | yes | Public `https://` URL |
| `events` | string[] |  | Actions to receive, e.g. `["tracking.event.appended"]` |

**Request (curl)**

```bash
curl -X POST https://lyverto.com/api/v1/hooks \
  -H "Authorization: Bearer lyv_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-system.example.com/lyverto" }'
```

**Response 201**

```json
{ "success": true, "data": { "id": "<uuid>", "url": "https://your-system.example.com/lyverto", "events": [], "secret": "whsec_<shown once>" } }
```

### `DELETE /hooks/{id}`

Unsubscribe. A key can only remove hooks it created.

Scope: `webhooks:manage`

**Request (curl)**

```bash
curl -X DELETE https://lyverto.com/api/v1/hooks/<id> -H "Authorization: Bearer lyv_YOUR_API_KEY"
```

**Response 204**

```json
(no body)
```

## Errors

| HTTP | `reason` | What to do |
| --- | --- | --- |
| 400 | `validation_failed` | Fix the field named in `details` or the message |
| 400 | `batch_too_large` | Split the batch to your plan’s size |
| 401 | `invalid_api_key` | Check the key; it may be revoked or expired |
| 403 | `scope_required` | Create a key with the scope named in the message |
| 404 | `not_found` | Nothing of yours matches |
| 409 | `conflict` / `mapping_paused` | The current state prevents it — read the message |
| 429 | `rate_limited` | Wait `Retry-After` seconds, then retry |
| 429 | `quota_exceeded` | Daily quota used; resets 00:00 UTC, or upgrade |
| 500 | — | Retry with backoff; quote the `Request-Id` if it persists |

Fields the API does not recognise are ignored, not refused, so an extra field never breaks a request. The response lists each one in `warnings` — for example `{ "field": "custmerEmail", "message": "Unknown field; ignored." }` — so a typo shows up in your logs instead of silently doing nothing.
