VitrinaAPI

Search, create and merge contacts

The people a workspace talks to, their companies and their channels.

A workspace keeps one row per person it talks to. The row's shape doesn't change with the line of business. The same one serves a patient booking an appointment, somebody buying a car, a tenant, a student. Whatever is specific to a line of business lives in the attributes a workspace defines, never in the shape of a contact.

Reading needs contacts:read; writing, contacts:write. Companies use their own pair: companies:read / companies:write.

The shape of a contact

curl "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": {
    "id": "5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "lifecycle_stage": "prospect",
    "job_title": "Gerente de operaciones",
    "company_id": "9a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d",
    "origin_channel": "manual",
    "email_consent": false,
    "email_status": "subscribed",
    "merged_into_contact_id": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "display_name": "María González",
    "named": true,
    "channels": ["whatsapp"],
    "lead_sources": ["website"]
  }
}

Three fields are worth reading before you use them.

display_name and named travel together. display_name is never empty: when a contact has no name it falls back to a formatted phone, an email or a handle. named says whether display_name is the person's name. Check named before putting display_name into text a customer reads. Otherwise a template will greet someone by their own phone number.

lead_sources outranks origin_channel. Both answer "where did this person come from". lead_sources is the real provenance of their leads; origin_channel is what somebody typed. Derived beats declared: origin_channel is only the answer when lead_sources is empty.

id is a uuid and external_id is a label. The uuid is the contact's identity in Vitrina. external_id is your own key: the id in the system you imported from, a meli:{id}, a WhatsApp wa_id. It's what the importer matches on to recognise a row that already exists.

Creating does not deduplicate; importing does

POST /contacts writes a row and nothing else. Send the same email twice and you get two contacts.

curl -X POST https://api.vitrinadev.com/api/v1/contacts \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6b1f0b3a-9d12-4a4e-9b60-5bb4f2a51f11" \
  -d '{
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "lifecycle_stage": "prospect"
  }'

Only name is required. A contact with neither email nor phone is creatable, and stays unreachable on every channel until one is added. lifecycle_stage starts at unknown and email_consent starts false: creating a contact never grants consent.

When what you want is "create or update", use POST /contacts/import, which does compare identity:

curl -X POST https://api.vitrinadev.com/api/v1/contacts/import \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "csv": "name,email,phone\nPedro Ramírez,[email protected],+56987654321\n",
    "dry_run": true
  }'
{
  "data": {
    "total": 1,
    "created": 1,
    "updated": 0,
    "skipped": 0,
    "failed": 0,
    "dry_run": true,
    "rows": [{ "row": 1, "action": "create", "name": "Pedro Ramírez" }]
  }
}

Matching runs in order: external_id, then email, then phone. A match is updated by filling blanks, never by overwriting a value that was already there. dry_run: true runs the whole pipeline, including the real duplicate lookup, and writes nothing. The report it returns has the same shape as the committed one, so you can show it and ask for confirmation before anything touches the book.

Searching

curl "https://api.vitrinadev.com/api/v1/contacts/search?q=gonz&limit=25" \
  -H "Authorization: Bearer $VITRINA_KEY"

q searches name, email, phone and external_id at once, ignoring case and accents ("Jose" finds "José").

meta.total is the number of matches, not the page size. It is the denominator a pager needs; asking for limit=1000 does not mean there are a thousand.

The filters combine: lifecycle_stage (one, or several comma-separated), channel, lead_source, tag_id, company_id and exclude_id. company_id=none selects the contacts that belong to no company.

For the denominators of the whole view, GET /contacts/stats computes them over the entire book rather than the loaded page.

Merging duplicates

A merge cannot be undone. The contact in the path survives; every id in secondary_ids becomes a tombstone.

curl -X POST "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/merge" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "secondary_ids": ["8c41b0d2-6e5f-4a19-b73c-0d5e9f2a1b84"] }'
{
  "data": {
    "primary": { "id": "5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c", "email": "[email protected]" },
    "merged_count": 1,
    "conversations_reassigned": 3,
    "filled_fields": ["email"]
  }
}

Conversations, channel identities, leads, tickets and commercial documents move onto the survivor. Name, email, phone, language, country, brand, avatar and the tax id with its kind are filled only where the survivor was empty. filled_fields names the columns that gained something: field names, never values.

The tombstones keep answering GET /contacts/{id}, with merged_into_contact_id pointing at the survivor. That is why an id you stored months ago is not lost: you follow the pointer.

There are two different reads for finding what to merge: GET /contacts/duplicates sweeps the whole book and returns clusters, and GET /contacts/{id}/duplicates proposes candidates for one contact.

Channels: how you reach them

The contact's phone field is a value on the card. What decides which person an inbound message lands on is the channel table.

curl -X POST "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/channels" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "whatsapp", "identifier": "+56912345678", "label": "Personal", "verified": true }'

kind is one of email, phone, whatsapp, instagram, messenger, web, sms, tiktok. An address a person mentioned mid-conversation is just a field until it exists as a channel. Only then does their next message arrive as them rather than as a stranger. verified asserts that the provider confirmed the identity, not that somebody believed it.

Channels survive a merge: they move onto the survivor.

Attributes: where the line of business lives

The shape of a contact is the same in every workspace. "Budget", "insurer" or "course" are attributes each workspace defines.

curl -X PUT "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/attributes" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "attributes": [{ "key": "presupuesto", "value": "18000000" }] }'

The read merges stored values with the workspace's definitions, so one call is enough to draw a form with its labels and types. An attribute that's defined but unset comes back with id: null and value: null, which is how you know there's an empty field to draw.

A file-typed attribute isn't written here. Its bytes go through POST /contacts/{id}/attributes/{key}/file, a multipart/form-data with one file part and a 25 MB ceiling. The stored value is a descriptor, never a URL. The download goes through the API, not a signed link.

There are two surfaces and they answer different questions.

email_consent is the gate on email marketing, and the count of reachable, consenting contacts is the billed unit. A contact is reachable when it has a valid email address, email_consent is true, email_status is subscribed, and it is not merged, archived or blocked: all four, not just consent.

curl "https://api.vitrinadev.com/api/v1/contacts/marketable-count" \
  -H "Authorization: Bearer $VITRINA_KEY"
{ "data": { "marketable": 62 } }

POST /contacts/bulk-consent raises the flag for up to a thousand contacts. It records an assertion; it doesn't obtain consent and stores nothing about when, how or on what wording it was given. Whoever calls it is declaring that consent exists and remains responsible for proving it. flipped is normally lower than the number of ids sent, because contacts that already consented and contacts with no email are skipped. That isn't an error.

The other surface IS evidence. POST /contacts/{id}/outbound-preferences appends one fact to the preference ledger: a channel, a scope (marketing, service, promised_followup, all_proactive), a status and, when there is one, the customer message that proves it.

curl -X POST "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/outbound-preferences" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "scope": "marketing",
    "status": "blocked",
    "legal_basis": "Asked in writing not to receive promotions"
  }'

It's an append-only ledger: revoking is posting again with the opposite status, and the superseded row stays readable. That's why there's no PATCH and no DELETE. A marketing block doesn't block service messages; a live all_proactive outranks every scope. The send policy enforces these facts. A later send to that contact in that scope is refused with a Bloqueo, not silently dropped.

Moderation

Three switches share a body ({ "value": true }) and have different consequences.

EndpointWhat it does
POST /contacts/{id}/blockStamps blocked_at and moves lifecycle_stage to blocked. The next conversation is born filtered.
POST /contacts/{id}/report-spamThe same, with a different reason on record.
POST /contacts/{id}/archiveDirectory hygiene only: it disappears from the list and the counts, and everything that references it stays intact. Does not touch lifecycle_stage.

Clearing a block resets lifecycle_stage to unknown: the previous commercial stage is not recoverable, so read it first if it matters.

Archiving is the closest thing to a delete there is: a contact is never removed, because conversations, leads and documents name it.

To silence the AI without taking the conversation out of the inbox there's POST /contacts/{id}/bot-replies-disabled. That's a different thing: the message still arrives, the conversation is still visible and assignable, and a person answers it.

Companies

A company groups contacts. It isn't the legal entity. The tax id, the registered name, the line of business and the signatory live on the contact that represents the company (person_kind: "juridica"). That contact is who a contract or an invoice is made out to.

curl -X POST https://api.vitrinadev.com/api/v1/companies \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Constructora Andes SpA", "domain": "andes.cl", "industry": "Construcción" }'

The link lives on the contact: set it with PATCH /contacts/{id} and company_id, clear it with company_id: null, and list a company's people with GET /contacts/search?company_id=<uuid>. GET /companies returns each company with its contact_count of live contacts.

Deleting a company is permanent and deletes nobody: the contacts stay, with company_id set to null.

What Vitrina tells you

Three events report what happens to a contact.

EventWhen
contact.createdA new person appeared, by any path: the API, an import (one per created row), an inbound message from an unknown number, a portal lead, a website form, a call. Once per contact.
contact.updatedThe contact's own columns were written. data.updated_fields names which: field names, never values.
contact.mergedContacts were merged. resource.id is the survivor and changes.contact_id.from lists the tombstones.

The default delivery is the notice: what happened, about which resource, who did it and when, with a resource.url you read with your own credential. That's where permissions apply and where the read is logged. Turning on "include resource data" adds data. Even then two things never travel in any contact event: the legal identity (tax id, registered name, line of business, signatory) and the structured address. Read those with GET /contacts/{id} and a credential holding contacts:read.

contact.merged is the one to listen to if you store Vitrina ids. The notice alone, with no data, already carries in changes.contact_id the ids that died and the one that survived. You repoint your own records without asking for personal data.

Who can see what

The directory belongs to the workspace. Row-level visibility ("sees only what is assigned") narrows conversations, leads and tickets on their own endpoints; the contact card stays outside that trim. A personal token reads exactly what that person's own session reads.

What does narrow a contact is scopes. A credential holding contacts:write but not contacts:read gets write responses without the legal identity and without the structured address. Everything else comes back unchanged, and the trim is the same on every surface.

Things people forget

  • POST /contacts doesn't deduplicate. Search first, or import.
  • The search's meta.total counts matches, not rows returned.
  • A merge can't be undone, and the tombstones keep answering.
  • Check named before using display_name in text a customer reads.
  • bulk-consent changes the billed count and has no bulk revoke.
  • A published POST accepts Idempotency-Key: retry with the same key and you get the same response instead of a second contact.

On this page