VitrinaAPI

Move leads across the board

The opportunity a workspace is working, and the board it moves across.

Somebody on the team works one concrete opportunity until it closes, and that opportunity is a lead. It has a contact, a value, an owner, and it lives on a stage of a pipeline. It serves a patient asking about a treatment, somebody asking about a car, or a company asking for a quote.

A lead is not a conversation. The customer comes back next week on another channel and it's still the same opportunity. That's why a lead spans several conversations, never just one.

Reading needs leads:read; writing, leads:write. Pipelines and stages have their own: pipelines:read / pipelines:write and stages:read / stages:write.

The board first

A lead needs a sales pipeline to live on. Every workspace ships with one, and ?include=counts adds how many cards each board is holding:

curl "https://api.vitrinadev.com/api/v1/pipelines?include=counts" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "id": "44444444-0000-4000-8000-000000000001",
      "tenant_id": "00000000-0000-4000-8000-000000000001",
      "name": "Sales",
      "description": "Track one commercial opportunity from the first concrete inquiry to a won, lost or unqualified outcome.",
      "slug": "generic-sales",
      "kind": "sales",
      "is_fallback": true,
      "template_key": "generic_sales",
      "template_version": 1,
      "template_locale": "en",
      "card_count": 13,
      "created_at": "2026-09-04T19:07:05.226Z",
      "updated_at": "2026-09-04T19:07:05.226Z"
    }
  ],
  "meta": { "total": 1 }
}

Three fields worth reading carefully:

kind decides what the board accepts. sales holds leads and ticket holds tickets; the third value, vehicle, belongs to one line of business and doesn't appear anywhere else in this chapter. A lead can only live on a sales board; naming another one is a 400.

is_fallback marks the default board of that kind. It's where a card nobody routed lands, and where cards move when another board of the same kind is deleted. There's exactly one per kind. Sending is_fallback: true on another board promotes it and demotes the current holder; sending false does nothing, because the workspace may never be left without one.

card_count is the work in flight and never a historical total: open leads on a sales board, tickets that are neither resolved nor closed on a support board. A board that finished everything reads 0. Without the parameter, the response carries no card_count.

The columns

curl https://api.vitrinadev.com/api/v1/pipelines/44444444-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer $VITRINA_KEY"

The response carries the board with its columns nested under stage[], in position order. Each one:

{
  "id": "55555555-0000-4000-8000-000000000002",
  "tenant_id": "00000000-0000-4000-8000-000000000001",
  "pipeline_id": "44444444-0000-4000-8000-000000000001",
  "name": "Contacted",
  "description": "A teammate has made meaningful contact and is understanding the need.",
  "slug": "contacted",
  "position": 1,
  "category": "open",
  "is_terminal": false,
  "won_state": null,
  "sla_days": 3,
  "allowed_transitions_to": [
    "55555555-0000-4000-8000-000000000003",
    "55555555-0000-4000-8000-000000000006",
    "55555555-0000-4000-8000-000000000007"
  ],
  "ai_agent_id": null,
  "template_stage_key": "contacted",
  "created_at": "2026-09-04T19:07:05.226Z"
}

category is the vocabulary the logic depends on: open, won, lost, unqualified and, on ticket boards, resolved. Never branch on the column's name: "Won", "Happy close" and "Delivered" are each workspace's own decision; category: "won" is not.

is_terminal and won_state are derived from category and are rejected as input: sending either is a 400 that names the key.

sla_days is the ageing threshold in days: an integer of at least 1, or null for "no limit". Omitting the key leaves the current value alone; sending null clears it.

allowed_transitions_to is the graph: which columns a card may jump to from here. Empty means no restriction. A jump the graph forbids is refused when the card is moved and never when the column is saved.

Laying out a board in one go

Writing the columns by hand works (POST /stages, or POST /stages/bulk for up to 100 in one trip), but the platform ships versioned, localized templates:

curl "https://api.vitrinadev.com/api/v1/pipeline-templates?locale=en" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "key": "generic_sales",
      "version": 1,
      "kind": "sales",
      "vertical": "generic",
      "name": "Sales",
      "description": "General sales pipeline: track one commercial opportunity from the first concrete inquiry to a won, lost, or unqualified outcome.",
      "stage_count": 9,
      "locale": "en",
      "locales_available": ["en", "es"]
    }
  ],
  "meta": { "total": 6 }
}

vertical filters the catalogue: without the parameter you get them all, with ?vertical=generic only the ones that fit any workspace. Each line of business brings its own on top, listed in its own group of these docs.

vertical accepts generic, automotive and healthcare. GET /pipeline-templates/{key}/preview returns exactly the board an apply would create, writing nothing. POST /pipeline-templates/{key}/apply materialises it into rows the workspace owns and can edit like any other. Applying is idempotent per template: the second call returns the board that already exists with already_applied: true and changes nothing.

Opening a lead

curl -X POST https://api.vitrinadev.com/api/v1/leads \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f1c4d2e-5a6b-4c7d-8e9f-0a1b2c3d4e5f" \
  -d '{
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "pipeline_id": "44444444-0000-4000-8000-000000000001",
    "title": "Maria — quote requested over WhatsApp",
    "source": "manual",
    "intent": "buy",
    "value_amount": 8990000,
    "value_currency": "CLP",
    "expected_close_at": "2026-10-15T00:00:00.000Z",
    "owner_user_id": "11111111-0000-4000-8000-000000000001"
  }'
{
  "data": {
    "id": "99999999-0000-4000-8000-000000000001",
    "display_id": "L-89",
    "title": "Maria — quote requested over WhatsApp",
    "status": "open",
    "source": "manual",
    "intent": "buy",
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "pipeline_id": "44444444-0000-4000-8000-000000000001",
    "stage_id": "55555555-0000-4000-8000-000000000001",
    "owner_user_id": "11111111-0000-4000-8000-000000000001",
    "team_id": null,
    "value_amount": 8990000,
    "value_currency": "CLP",
    "expected_close_at": "2026-10-15T00:00:00.000Z",
    "score": null,
    "temperature": null,
    "closed_at": null,
    "won_lost_reason": null,
    "contact": {
      "id": "22222222-0000-4000-8000-000000000001",
      "name": "Maria Gonzalez",
      "email": "[email protected]",
      "phone": "+56912345001"
    },
    "stage": {
      "id": "55555555-0000-4000-8000-000000000001",
      "name": "New",
      "slug": "new",
      "position": 0,
      "won_state": null,
      "pipeline_id": "44444444-0000-4000-8000-000000000001"
    },
    "pipeline": {
      "id": "44444444-0000-4000-8000-000000000001",
      "kind": "sales",
      "name": "Sales"
    }
  }
}

pipeline_id is optional: without it the lead lands on the default sales board. So is stage_id: it lands on the first column.

source says where it came from (conversation, website, marketplace, manual, import, …) and intent says what for (buy, sell, financing, trade_in; buy by default). Both are set at creation and are absent from the update body.

Send team_id without owner_user_id and the team's rotation picks the owner.

Idempotency-Key works on every published POST: retrying with the same key returns the same response instead of opening a second lead. It is described in Errors.

id is a uuid; L-89 is a label

display_id is the ID visible: short, readable, the one a person says out loud. Endpoints that take {id} accept it as a shortcut (GET /leads/L-89 works), and that is where its job ends.

Nothing stores it as a reference. Inside a body, a response or an event, a field that points at another record (contact_id, pipeline_id, stage_id, owner_user_id) is always a uuid. Sending L-89 where a uuid belongs is a 400 that names the field.

From a conversation

When the opportunity is born on a thread that already exists, POST /leads/from-conversation resolves the contact off the conversation and records it as the lead's origin. It inherits the thread's owner unless you name one:

curl -X POST https://api.vitrinadev.com/api/v1/leads/from-conversation \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "conversation_id": "bbbbbbbb-0000-4000-8000-000000000001" }'

A whole list

POST /leads/import takes up to 2000 rows and matches each to a contact by email or phone. Partial success is the normal outcome and the status is 200 either way: the report says row by row what happened.

{
  "data": {
    "total": 2,
    "inserted": 1,
    "failed": 1,
    "rows": [
      { "row": 1, "ok": true, "lead_id": "99999999-0000-4000-8000-000000000001" },
      { "row": 2, "ok": false, "error": "No contact matched +56988887777" }
    ]
  }
}

Moving the card

curl -X PUT https://api.vitrinadev.com/api/v1/leads/L-89/stage \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "stage_id": "55555555-0000-4000-8000-000000000002",
    "reason": "we called and she confirmed the time"
  }'

The target column has to belong to the same board and be reachable from the current one. If the graph forbids it, that is a 400 and nothing is written.

Landing on a terminal column closes the lead: status, closed_at and won_lost_reason are derived from the category of the column it landed on. Dragging the card to "Won" and calling PUT /leads/{id}/won do exactly the same thing.

The three outcomes

EndpointWhat it meansEvent
PUT /leads/{id}/wonThe opportunity closed in your favourlead.won
PUT /leads/{id}/lostThere was an opportunity and it did not closelead.lost
PUT /leads/{id}/unqualifyThere was never an opportunitylead.unqualified

unqualified is not a soft lost. Unqualified leads are excluded from win rate on both sides, so discarding early doesn't count as losing.

Each one moves the lead to the lowest-position column of that category on the board. If the board has none, that's a 400: add that column before you retry.

/won is the only one that does anything with value_amount: there the number is the real closing price, distinct from any earlier estimate, and it's stamped with value_amount_close_confirmed_at. Omit it and the estimate is left alone.

PUT /leads/{id}/reopen puts the lead back on the first open column and clears closed_at. It doesn't erase the close: the history keeps it, so a reopened-and-re-won lead reads as the two facts it was.

Changing board

PUT /leads/{id}/pipeline reclassifies the opportunity onto another sales board and lands it on that board's first column, re-deriving the lifecycle from there. It announces lead.pipeline_changed, which carries both changes: the board and the column.

What the lead is asking for

The lead is the opportunity; an interest is the thing being asked for, and there can be several:

curl -X POST https://api.vitrinadev.com/api/v1/leads/L-89/interests \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "resource_type": "service",
    "resource_id": "e1e1e1e1-0000-4000-8000-000000000001",
    "quantity": 1,
    "priority": "high",
    "notes": "prefers an afternoon slot"
  }'

resource_type is vehicle, property, product, service, repair_order or custom. resource_id points at the record when it exists in Vitrina. When it doesn't, title carries it in words. One of the two is required.

The conversations one opportunity spans

curl https://api.vitrinadev.com/api/v1/leads/L-89/conversations \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
      "display_id": "C-122",
      "channel": "whatsapp",
      "status": "open",
      "is_primary": true,
      "is_origin": true,
      "linked_at": "2026-09-18T13:44:02.115Z",
      "linked_by_type": "system",
      "last_message_date": "2026-09-21T14:01:58.000Z"
    }
  ],
  "meta": { "total": 1 }
}

is_origin marks where the opportunity started, a historical fact that doesn't move. is_primary marks the thread it's being worked on now, which does change. POSTing the same pair again is idempotent: it answers linked: false and writes no second history line.

Reading the funnel

Four aggregate reads, each optionally narrowed to one board:

  • GET /leads/summary: how many are open, won, lost and unqualified, and what they're worth.
  • GET /leads/funnel: one row per column, with how many open leads sit there, what they're worth, and the median hours they've been sitting. That last number says where the funnel is stuck.
  • GET /leads/kanban: the board with its cards already nested, up to 200 per column.
  • GET /leads/win-rate/{dimension}: won / (won + lost) grouped by owner_user_id, team_id or source.

Money totals come broken down by currency:

{
  "data": {
    "open": 13,
    "won": 2,
    "lost": 1,
    "unqualified": 0,
    "total_value_open": 194077750,
    "total_value_open_by_currency": { "CLP": 193990000, "USD": 42750 },
    "is_mixed_currency_open": true
  }
}

is_mixed_currency_open warns that the flat total adds amounts in different currencies. A workspace working in one currency can ignore it; one that is not has to read the breakdown.

Two "temperatures" that are not the same thing

?temperature= on GET /leads is a shortcut over score: cold 0–39, warm 40–69, hot 70–100, unscored for no score at all. Explicit min_score and max_score win over it.

The lead's own temperature field is something else: a judgement somebody recorded about whether the opportunity is going cold, with its temperature_reason and temperature_at. It isn't derived from the score, and the two disagreeing is information rather than a bug. On PUT /leads/{id}, omitting the key leaves it alone, and sending null retracts the judgement, which is how a wrong verdict is undone.

Who sees what

leads:read is not contacts:read. The contact, stage, pipeline and solicitud blocks embedded in a lead are trimmed for a caller without that block's own read scope; the bare contact_id / stage_id / pipeline_id are always there.

And if a member's role restricts record visibility, the list, the board and the aggregate reads show only their leads. It is not a filter the client can drop: somebody else's lead answers 404, not 403.

The events

Every change is announced by webhook. The seven this domain publishes:

EventWhen
lead.createdAn opportunity was opened, by any path
lead.stage_changedThe card changed column
lead.pipeline_changedThe card changed board
lead.assignedThe owner or the team changed
lead.wonIt closed in your favour
lead.lostIt did not close
lead.unqualifiedThere was never an opportunity

Every column change fires lead.stage_changed: the PUT, the drag on the board, the AI tool, the quick action that closes the lead. A receiver tracking progress needs that event and no other. There are two exceptions. A no-op move, where the card was already on that column, fires nothing; and a board change is already reported by lead.pipeline_changed.

The envelope carries changes with the fromto pair:

{
  "id": "bc999b31-075f-4ae4-b789-b4000f557ff1",
  "type": "lead.stage_changed",
  "version": 1,
  "created_at": "2026-09-22T08:39:25.338Z",
  "tenant_id": "00000000-0000-4000-8000-000000000001",
  "resource": {
    "type": "lead",
    "id": "99999999-0000-4000-8000-000000000001",
    "url": "https://api.vitrinadev.com/api/v1/leads/99999999-0000-4000-8000-000000000001"
  },
  "changes": {
    "stage": {
      "from": "55555555-0000-4000-8000-000000000001",
      "to": "55555555-0000-4000-8000-000000000002"
    }
  },
  "author": {
    "kind": "member",
    "id": "11111111-0000-4000-8000-000000000001",
    "name": "Camila Rojas",
    "via": { "kind": "connected_app", "name": "Claude" }
  },
  "data_omitted": "not_requested"
}

changes is keyed by what changed (stage, pipeline) and never by the column that holds it: each entry is already a pair of identifiers. It travels even when the envelope carries no data, so it never contains anything personal.

The author

author says who made the change. Someone acting through a connected app or a personal token is still the author. The credential is recorded beside them, in via: "Camila via Claude", never in their place.

An API key signs as itself ("kind": "api_key"), under the name it had at that moment. Renaming or revoking it doesn't rewrite history. The AI agent is ai_agent, and the platform acting on its own is system.

The same authorship lands in the lead's history, which is read in full with GET /leads/{id}/activity and comes back newest first.

Deleting

DELETE /leads/{id} removes the opportunity and its history; the contact is untouched. It's for a lead that should never have been opened: a duplicate, a test. One that really ended goes to /lost or /unqualify, which keep the record and the reason.

Deleting a column or a board never deletes its cards: they are moved first, to whatever ?reassign_to= names, or to the sibling column / the kind's default board. The response says how many moved and where to. The default board of a kind cannot be deleted: promote another one first.

On this page