VitrinaAPI

Contacts

The people a workspace talks to: search, create, correct, merge, tag, and the channels and attributes hanging off each one.

Beta
This may change at any time, with a changelog entry and a notice to recent callers — see versioning.

Download the full API pública projection: openapi.json.

Search, create and merge contacts explains this resource in prose, with runnable examples.

MethodPathWhat it does
POST/contactsCreate a contact
GET/contacts/{id}Get a contact
PATCH/contacts/{id}Update a contact
POST/contacts/{id}/archiveArchive or unarchive a contact
GET/contacts/{id}/attachmentsFiles exchanged with a contact across every conversation
GET/contacts/{id}/attributesCustom attributes on a contact, merged with their definitions
PUT/contacts/{id}/attributesUpsert custom attributes on a contact
DELETE/contacts/{id}/attributes/{key}Delete one custom attribute from a contact
GET/contacts/{id}/attributes/{key}/fileDownload a contact’s stored attribute file
POST/contacts/{id}/attributes/{key}/fileUpload a file into a contact’s file attribute
POST/contacts/{id}/blockBlock or unblock a contact
POST/contacts/{id}/bot-replies-disabledTurn the AI off (or back on) for one contact, on every channel
GET/contacts/{id}/channelsA contact’s linked channel identities
POST/contacts/{id}/channelsLink a channel identity to a contact
DELETE/contacts/{id}/channels/{channelId}Unlink a channel identity
PATCH/contacts/{id}/channels/{channelId}Set a channel identity’s verified flag
GET/contacts/{id}/conversationsA contact’s conversations across every channel
GET/contacts/{id}/duplicatesSuggest duplicate contacts for one contact
POST/contacts/{id}/mergeMerge other contacts into this one
GET/contacts/{id}/notesA contact’s internal notes
POST/contacts/{id}/notesAdd a note to a contact
DELETE/contacts/{id}/notes/{noteId}Delete a contact note
PATCH/contacts/{id}/notes/{noteId}Edit a contact note
GET/contacts/{id}/outbound-preferencesA contact’s scope-aware consent / do-not-contact ledger
POST/contacts/{id}/outbound-preferencesRecord an outbound preference (consent or DNC) for a contact
POST/contacts/{id}/report-spamFlag or unflag a contact as spam
GET/contacts/{id}/tagsTags on a contact
POST/contacts/{id}/tagsTag a contact
DELETE/contacts/{id}/tags/{tagId}Untag a contact
GET/contacts/{id}/timelineOne chronological feed of everything that happened to a contact
POST/contacts/bulk-consentRecord email-marketing consent for many contacts
GET/contacts/duplicatesDuplicate contact clusters (live contacts only)
GET/contacts/exportExport contacts as a CSV file
POST/contacts/importImport contacts (dry_run returns a preview without writing)
GET/contacts/marketable-countHow many contacts email marketing may reach
GET/contacts/searchSearch contacts (paged; meta.total is the match count, not the page size)
GET/contacts/statsContact directory facet counts (live contacts only)
POST/contacts/tags/bulkTag many contacts at once

POST /contacts

Create a contact

Only name is required — a contact with neither email nor phone is creatable, and is unreachable on every channel until one is added.

This creates a row directly and does NOT deduplicate: an existing contact with the same email or phone is not matched or merged, you simply get a second contact. Use GET /contacts/search first, or POST /contacts/import, which does match on identity.

lifecycle_stage defaults to unknown. A new contact is not marketable: email_consent starts false and is never granted implicitly by creation.

origin_channel is the operator-declared «Canal de origen», and it is a FALLBACK for provenance rather than the truth: the contacts list derives Origen from the distinct lead.source values of the contact’s leads and only falls back to this when there are none. Its vocabulary is lead.source’s, value for value, so both render as one badge and answer one filter.

Emits contact.created — as does every other way a contact comes into being (an inbound message, an import row, a portal lead, a form, a call), exactly once each.

Answers 201 with \{ data \} and no meta — the created contact, nothing else. display_name is never empty and named says whether it IS the person’s name: when a contact has none, display_name falls back to a formatted phone, an email or a channel handle and named is false. Check named before putting display_name into text a customer reads — otherwise a template greets somebody by their own phone number.

Body

FieldTypeRequiredConstraints
namestringyesmín. 1, máx. 200
emailstring | nullemail, máx. 254
phonestring | nullmín. 1, máx. 50
languagestring | nullmín. 2, máx. 10
countrystring | nullmín. 2, máx. 10
brandstring | nullmín. 1, máx. 120
lifecycle_stageunknown \prospect \qualified_prospect \
job_titlestring | nullmáx. 200
citystring | nullmáx. 200
company_idstring | null
socialobject | null
origin_channelconversation \marketplace \manual \
tax_idstring | nullmín. 1, máx. 64
tax_id_kindrut \passport \foreign_tax_id
person_kindnatural \juridica
legal_namestring | nullmáx. 300
girostring | nullmáx. 300
representative_contact_idstring | null
address_streetstring | nullmáx. 200
address_numberstring | nullmáx. 40
address_unitstring | nullmáx. 60
comuna_codestring | nullpatrón ^[0-9]{5}$
curl -X POST https://api.vitrinadev.com/api/v1/contacts \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "job_title": "Gerente de operaciones",
    "lifecycle_stage": "prospect",
    "origin_channel": "manual",
    "company_id": "15151515-0000-4000-8000-000000000001"
  }'

Example response (201)

{
  "data": {
    "id": "22222222-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "language": "es",
    "country": "CL",
    "brand": null,
    "lifecycle_stage": "prospect",
    "job_title": "Gerente de operaciones",
    "city": "Providencia",
    "company_id": "15151515-0000-4000-8000-000000000001",
    "social": {
      "instagram": "@mariagonzalez"
    },
    "email_consent": false,
    "email_status": "subscribed",
    "avatar_url": null,
    "social_stats": {},
    "origin_channel": "manual",
    "merged_into_contact_id": null,
    "merged_at": null,
    "blocked_at": null,
    "archived_at": null,
    "spam_at": null,
    "bot_replies_disabled_at": null,
    "bot_replies_disabled_by": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "updated_at": "2026-09-21T14:03:11.000Z",
    "tax_id": null,
    "tax_id_kind": null,
    "person_kind": null,
    "legal_name": null,
    "giro": null,
    "representative_contact_id": null,
    "address_street": null,
    "address_number": null,
    "address_unit": null,
    "comuna_code": null,
    "region_code": null,
    "address_source": null,
    "display_name": "María González",
    "named": true
  }
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}

Get a contact

One contact by uuid, with the same channels / lead_sources badge arrays a search row carries — so a deep link and a list row render identically.

A MERGED contact still answers here rather than 404ing, carrying merged_into_contact_id and merged_at. That is deliberate: an integration holding an id that was merged away can follow the pointer to the survivor instead of losing the person. display_name is never empty and named says whether it IS the person’s name: when a contact has none, display_name falls back to a formatted phone, an email or a channel handle and named is false. Check named before putting display_name into text a customer reads — otherwise a template greets somebody by their own phone number.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/contacts/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "id": "22222222-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "language": "es",
    "country": "CL",
    "brand": null,
    "lifecycle_stage": "prospect",
    "job_title": "Gerente de operaciones",
    "city": "Providencia",
    "company_id": "15151515-0000-4000-8000-000000000001",
    "social": {
      "instagram": "@mariagonzalez"
    },
    "email_consent": false,
    "email_status": "subscribed",
    "avatar_url": null,
    "social_stats": {},
    "origin_channel": "manual",
    "merged_into_contact_id": null,
    "merged_at": null,
    "blocked_at": null,
    "archived_at": null,
    "spam_at": null,
    "bot_replies_disabled_at": null,
    "bot_replies_disabled_by": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "updated_at": "2026-09-21T14:03:11.000Z",
    "tax_id": null,
    "tax_id_kind": null,
    "person_kind": null,
    "legal_name": null,
    "giro": null,
    "representative_contact_id": null,
    "address_street": null,
    "address_number": null,
    "address_unit": null,
    "comuna_code": null,
    "region_code": null,
    "address_source": null,
    "display_name": "María González",
    "named": true,
    "channels": [
      "whatsapp"
    ],
    "lead_sources": [
      "website"
    ]
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

PATCH /contacts/{id}

Update a contact

Partial update — send only the fields you are changing; at least one is required. null clears a nullable field (including origin_channel, editable after the fact so an operator who learns the walk-in was a referral can correct it).

A name written here is an OPERATOR’s word and outranks every automatic source afterwards: a later channel profile or address-book sync will not overwrite it. Clearing the name clears that provenance too, so automatic sources may fill it again.

A merged contact is read-only; patching one answers 404.

Legal identity is validated properly, not just accepted: tax_id is checked digit and all, tax_id and tax_id_kind always travel together, and a persona jurídica must be complete. A duplicate tax_id in the workspace is a 409 — one legal identity, one counterparty.

Emits contact.updated with the field names that were written. The legal identity (tax_id, tax_id_kind, person_kind, legal_name, giro, representative_contact_id) and the street address (address_street, address_number, address_unit, comuna_code) are withheld from a credential that does not hold contacts:read — including on this write’s own response, because otherwise a contacts:write-only key could read a RUT by writing an unrelated field. Everything else, region_code included, is returned unchanged.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
email_consentboolean
namestring | nullmín. 1, máx. 200
emailstring | nullemail, máx. 254
phonestring | nullmín. 1, máx. 50
languagestring | nullmín. 2, máx. 10
countrystring | nullmín. 2, máx. 10
brandstring | nullmín. 1, máx. 120
lifecycle_stageunknown \prospect \qualified_prospect \
job_titlestring | nullmáx. 200
citystring | nullmáx. 200
company_idstring | null
socialobject | null
origin_channelconversation \marketplace \manual \
tax_idstring | nullmín. 1, máx. 64
tax_id_kindrut \passport \foreign_tax_id
person_kindnatural \juridica
legal_namestring | nullmáx. 300
girostring | nullmáx. 300
representative_contact_idstring | null
address_streetstring | nullmáx. 200
address_numberstring | nullmáx. 40
address_unitstring | nullmáx. 60
comuna_codestring | nullpatrón ^[0-9]{5}$
curl -X PATCH https://api.vitrinadev.com/api/v1/contacts/<id> \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "lifecycle_stage": "customer",
    "job_title": "Directora de operaciones"
  }'

Example response (200)

{
  "data": {
    "id": "22222222-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "language": "es",
    "country": "CL",
    "brand": null,
    "lifecycle_stage": "customer",
    "job_title": "Directora de operaciones",
    "city": "Providencia",
    "company_id": "15151515-0000-4000-8000-000000000001",
    "social": {
      "instagram": "@mariagonzalez"
    },
    "email_consent": false,
    "email_status": "subscribed",
    "avatar_url": null,
    "social_stats": {},
    "origin_channel": "manual",
    "merged_into_contact_id": null,
    "merged_at": null,
    "blocked_at": null,
    "archived_at": null,
    "spam_at": null,
    "bot_replies_disabled_at": null,
    "bot_replies_disabled_by": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "updated_at": "2026-09-22T11:20:45.000Z",
    "tax_id": null,
    "tax_id_kind": null,
    "person_kind": null,
    "legal_name": null,
    "giro": null,
    "representative_contact_id": null,
    "address_street": null,
    "address_number": null,
    "address_unit": null,
    "comuna_code": null,
    "region_code": null,
    "address_source": null,
    "display_name": "María González",
    "named": true
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/archive

Archive or unarchive a contact

Directory hygiene, and the closest thing to a delete: an archived contact disappears from the directory and its facet counts while everything that references it — conversations, leads, notas de venta — stays intact. Unlike block and report-spam it does NOT touch lifecycle_stage and does not filter future conversations.

\{ "value": false \} restores it. There is no hard delete for a contact in this API.

Emits contact.updated.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
valuebooleanyes
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/archive \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "value": true
  }'

Example response (200)

{
  "data": {
    "id": "22222222-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "language": "es",
    "country": "CL",
    "brand": null,
    "lifecycle_stage": "prospect",
    "job_title": "Gerente de operaciones",
    "city": "Providencia",
    "company_id": "15151515-0000-4000-8000-000000000001",
    "social": {
      "instagram": "@mariagonzalez"
    },
    "email_consent": false,
    "email_status": "subscribed",
    "avatar_url": null,
    "social_stats": {},
    "origin_channel": "manual",
    "merged_into_contact_id": null,
    "merged_at": null,
    "blocked_at": null,
    "archived_at": "2026-09-22T11:25:10.000Z",
    "spam_at": null,
    "bot_replies_disabled_at": null,
    "bot_replies_disabled_by": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "updated_at": "2026-09-22T11:25:10.000Z",
    "tax_id": null,
    "tax_id_kind": null,
    "person_kind": null,
    "legal_name": null,
    "giro": null,
    "representative_contact_id": null,
    "address_street": null,
    "address_number": null,
    "address_unit": null,
    "comuna_code": null,
    "region_code": null,
    "address_source": null,
    "display_name": "María González",
    "named": true
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/attachments

Files exchanged with a contact across every conversation

One row per FILE, not per message. Media lives in message.media_urls, a jsonb array, so a turn that carried three photos is three rows sharing one message_id.

Key the icon off kind, never off mime. kind is derived from message.type (image / audio / video pass through; anything else is document when the mime says application/* and file otherwise). mime and size are read from metadata on a best-effort basis and are null on effectively every row today — no inbound path fills them.

media_sensitivity: "sensitive" marks media the sensitive-media policy withheld from the model; clients render it behind a reveal.

meta.has_more comes from a limit + 1 probe.

ParameterInTypeRequiredConstraints
idpathuuidyes
limitqueryintegerno≥ 1, ≤ 100, por defecto 40
offsetqueryinteger | nullno≥ 0, por defecto 0
curl https://api.vitrinadev.com/api/v1/contacts/<id>/attachments \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "url": "https://media.vitrinadev.com/t/abc/1f2e3d.jpg",
      "kind": "image",
      "mime": null,
      "size": null,
      "media_sensitivity": null,
      "message_id": "12121212-0000-4000-8000-000000000001",
      "conversation_id": "bbbbbbbb-0000-4000-8000-000000000001",
      "created_at": "2026-09-21T16:41:02.000Z"
    }
  ],
  "meta": {
    "limit": 40,
    "offset": 0,
    "has_more": false
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/attributes

Custom attributes on a contact, merged with their definitions

The workspace’s own fields on this person. Values are merged with the custom_attribute DEFINITIONS (entity_type = contact), so one call is enough to render typed inputs: label, data_type, options, required and multiline come from the definition, value and source from the stored row.

A defined-but-unset attribute is returned with id: null and value: null — that is how a client knows to draw an empty field rather than nothing at all.

A file-typed row carries file_limits (the byte cap and the effective mime prefixes) straight off the server, so a client never hardcodes them. Treat the list as a picker HINT: the upload sniffs magic bytes and can still refuse a file the OS declared acceptable.

This is where a vertical lives. The shape of a contact is the same everywhere; «presupuesto», «previsión» or «curso» are attributes a workspace defined.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/contacts/<id>/attributes \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "20202020-0000-4000-8000-000000000001",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "key": "presupuesto",
      "value": "18000000",
      "source": "admin",
      "source_tool": null,
      "updated_at": "2026-09-22T11:21:30.000Z",
      "custom_attribute_id": "33333333-0000-4000-8000-000000000009",
      "label": "Presupuesto",
      "data_type": "number",
      "options": [],
      "required": false,
      "multiline": false,
      "unique_values": false,
      "file_limits": null,
      "pinned": false
    },
    {
      "id": null,
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "key": "contrato_firmado",
      "value": null,
      "source": null,
      "source_tool": null,
      "updated_at": null,
      "custom_attribute_id": "33333333-0000-4000-8000-00000000000a",
      "label": "Contrato firmado",
      "data_type": "file",
      "options": [],
      "required": false,
      "multiline": false,
      "unique_values": false,
      "file_limits": {
        "max_bytes": 26214400,
        "mime_prefixes": [
          "application/pdf",
          "image/"
        ],
        "accept": ".pdf,image/*"
      },
      "pinned": false
    }
  ]
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

PUT /contacts/{id}/attributes

Upsert custom attributes on a contact

Writes the attributes you send and leaves every other one alone — it is an upsert per key, not a replace of the set. To clear one, use DELETE /contacts/\{id\}/attributes/\{key\}.

A key with no definition is still stored (that is how an AI tool captures something the workspace has not modelled yet), and appears in the GET above with label and data_type null until somebody defines it through POST /custom-attributes.

A file-typed attribute is NOT written here — its bytes go through POST /contacts/\{id\}/attributes/\{key\}/file.

The response is the raw stored rows, without the definition merge: the caller already knows what it wrote.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
attributesobject[]yes
curl -X PUT https://api.vitrinadev.com/api/v1/contacts/<id>/attributes \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "attributes": [
      {
        "key": "presupuesto",
        "value": "18000000"
      }
    ]
  }'

Example response (200)

{
  "data": [
    {
      "id": "20202020-0000-4000-8000-000000000001",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "key": "presupuesto",
      "value": "18000000",
      "source": "admin",
      "source_tool": null,
      "created_at": "2026-09-22T11:21:30.000Z",
      "updated_at": "2026-09-22T11:21:30.000Z",
      "is_unique": false
    }
  ]
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

DELETE /contacts/{id}/attributes/{key}

Delete one custom attribute from a contact

Clears the value for that key on this contact. The DEFINITION stays in the workspace and the attribute stays set on every other contact — to retire the field itself, use DELETE /custom-attributes/\{id\}.

For a file-typed attribute this also retires the stored document. Deleting a key the contact never had is a 404.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
keypathstringyesmín. 1, máx. 120, patrón ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$
curl -X DELETE https://api.vitrinadev.com/api/v1/contacts/<id>/attributes/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/attributes/{key}/file

Download a contact’s stored attribute file

Streams the stored bytes back through the API rather than handing out a presigned URL, so the workspace check runs on every read, the object key never leaves the server, and the download is audited — who pulled which file, which matters when the file is a person’s document.

The response is served with nosniff, Cache-Control: private, no-store and a sandbox CSP, and anything whose stored mime is not a safe, previewable type comes back as application/octet-stream with Content-Disposition: attachment — a stored file never executes on our origin.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
keypathstringyesmín. 1, máx. 120, patrón ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$
curl https://api.vitrinadev.com/api/v1/contacts/<id>/attributes/<id>/file \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/attributes/{key}/file

Upload a file into a contact’s file attribute

multipart/form-data with one file part, at most 25 MB. The attribute must already be defined for entity_type = contact with data_type = file; a key that is not defined is a 404 and a key that is defined as something else is a 400, so a typo can never overwrite a text attribute with a file descriptor.

The bytes go to a private bucket and the attribute value becomes a DESCRIPTOR — \{ key, filename, mime, size, uploaded_at \} — never a URL. The mime is sniffed from the bytes, not trusted from the request.

One file per attribute: uploading again REPLACES the stored document and the replacement is recorded in the audit trail.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
keypathstringyesmín. 1, máx. 120, patrón ^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/attributes/<id>/file \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -F "file=@/path/to/file.png"

Example response (201)

{
  "data": {
    "key": "contrato_firmado",
    "filename": "contrato.pdf",
    "mime": "application/pdf",
    "size": 182334,
    "uploaded_at": "2026-09-22T11:22:10.000Z"
  }
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/block

Block or unblock a contact

\{ "value": true \} stamps blocked_at and moves the contact to lifecycle_stage: "blocked"; false clears the stamp and resets the stage to unknownthe previous commercial stage is not recoverable, so read it first if it matters.

Blocking is about the person, not about one thread: their next conversation is born filtered. To silence the AI for a contact while keeping the conversation in the inbox, use POST /contacts/\{id\}/bot-replies-disabled instead.

Emits contact.updated.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
valuebooleanyes
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/block \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "value": true
  }'

Example response (200)

{
  "data": {
    "id": "22222222-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "language": "es",
    "country": "CL",
    "brand": null,
    "lifecycle_stage": "blocked",
    "job_title": "Gerente de operaciones",
    "city": "Providencia",
    "company_id": "15151515-0000-4000-8000-000000000001",
    "social": {
      "instagram": "@mariagonzalez"
    },
    "email_consent": false,
    "email_status": "subscribed",
    "avatar_url": null,
    "social_stats": {},
    "origin_channel": "manual",
    "merged_into_contact_id": null,
    "merged_at": null,
    "blocked_at": "2026-09-22T11:24:02.000Z",
    "archived_at": null,
    "spam_at": null,
    "bot_replies_disabled_at": null,
    "bot_replies_disabled_by": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "updated_at": "2026-09-22T11:24:02.000Z",
    "tax_id": null,
    "tax_id_kind": null,
    "person_kind": null,
    "legal_name": null,
    "giro": null,
    "representative_contact_id": null,
    "address_street": null,
    "address_number": null,
    "address_unit": null,
    "comuna_code": null,
    "region_code": null,
    "address_source": null,
    "display_name": "María González",
    "named": true
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/bot-replies-disabled

Turn the AI off (or back on) for one contact, on every channel

Suppresses the AUTONOMOUS reply only. Inbound messages are still ingested and the conversation is still visible and assignable — the thread is answered by a person. Unlike block/report-spam this does NOT touch the contact’s lifecycle_stage and does NOT quarantine future conversations.

bot_replies_disabled_by records the caller, so a client can render «turned off by Camila on 3 September» without a second read. Clearing the flag clears both — a stale «who» next to an empty «when» is worse than nothing.

A single conversation can opt back into the AI with POST /conversations/\{id\}/bot-gate-override.

Emits contact.updated.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
valuebooleanyes
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/bot-replies-disabled \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "value": true
  }'

Example response (200)

{
  "data": {
    "id": "22222222-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "language": "es",
    "country": "CL",
    "brand": null,
    "lifecycle_stage": "prospect",
    "job_title": "Gerente de operaciones",
    "city": "Providencia",
    "company_id": "15151515-0000-4000-8000-000000000001",
    "social": {
      "instagram": "@mariagonzalez"
    },
    "email_consent": false,
    "email_status": "subscribed",
    "avatar_url": null,
    "social_stats": {},
    "origin_channel": "manual",
    "merged_into_contact_id": null,
    "merged_at": null,
    "blocked_at": null,
    "archived_at": null,
    "spam_at": null,
    "bot_replies_disabled_at": "2026-09-22T11:26:40.000Z",
    "bot_replies_disabled_by": "11111111-0000-4000-8000-000000000001",
    "created_at": "2026-09-21T14:03:11.000Z",
    "updated_at": "2026-09-22T11:26:40.000Z",
    "tax_id": null,
    "tax_id_kind": null,
    "person_kind": null,
    "legal_name": null,
    "giro": null,
    "representative_contact_id": null,
    "address_street": null,
    "address_number": null,
    "address_unit": null,
    "comuna_code": null,
    "region_code": null,
    "address_source": null,
    "display_name": "María González",
    "named": true
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/channels

A contact’s linked channel identities

The durable handles a contact is reachable on — a phone, a WhatsApp number, an Instagram id, an email address. This table, not contact.phone, is what an inbound message is resolved against, and it is what survives a merge: merging moves these rows onto the survivor so future inbound on any of them lands on the right person.

verified says the PROVIDER confirmed the identity, not that a human believed it.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/contacts/<id>/channels \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "16161616-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "kind": "whatsapp",
      "identifier": "+56912345678",
      "label": "Personal",
      "verified": true,
      "created_at": "2026-09-22T11:16:20.000Z",
      "updated_at": "2026-09-22T11:16:20.000Z",
      "contact_id": "22222222-0000-4000-8000-000000000001"
    }
  ],
  "meta": {
    "total": 1
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/channels

Link a channel identity to a contact

Adds one handle. kind is one of email|phone|whatsapp|instagram|messenger|web|sms|tiktok; identifier is the raw handle as the channel spells it (E.164 for phone-shaped kinds, the @handle or the provider id for social).

Linking is what makes a person’s next message arrive as THEM instead of as a stranger: an address a person mentioned mid-chat is only an attribute until it is a channel row.

Pass verified: true only when a provider confirmed the identity — this is a claim about evidence, not about confidence.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
kindemail \phone \whatsapp \
identifierstringyesmín. 1, máx. 254
labelstring | nullmáx. 120
verifiedboolean
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/channels \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "whatsapp",
    "identifier": "+56912345678",
    "label": "Personal",
    "verified": true
  }'

Example response (201)

{
  "data": {
    "id": "16161616-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "kind": "whatsapp",
    "identifier": "+56912345678",
    "label": "Personal",
    "verified": true,
    "created_at": "2026-09-22T11:16:20.000Z",
    "updated_at": "2026-09-22T11:16:20.000Z",
    "contact_id": "22222222-0000-4000-8000-000000000001"
  }
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

DELETE /contacts/{id}/channels/{channelId}

Unlink a channel identity

Removes the handle from the contact. The next inbound message on that handle will no longer resolve to this person and will create a new contact instead — which is the point when a handle was linked to the wrong one, and the cost when it was not.

ParameterInTypeRequiredConstraints
idpathuuidyes
channelIdpathuuidyes
curl -X DELETE https://api.vitrinadev.com/api/v1/contacts/<id>/channels/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429

PATCH /contacts/{id}/channels/{channelId}

Set a channel identity’s verified flag

The only editable field on a linked channel. To correct the handle itself, remove the row and link the right one — an identifier that changes under an id is how a message ends up on the wrong person.

ParameterInTypeRequiredConstraints
idpathuuidyes
channelIdpathuuidyes

Body

FieldTypeRequiredConstraints
verifiedbooleanyes
curl -X PATCH https://api.vitrinadev.com/api/v1/contacts/<id>/channels/<id> \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "verified": true
  }'

Example response (200)

{
  "data": {
    "id": "16161616-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "kind": "whatsapp",
    "identifier": "+56912345678",
    "label": "Personal",
    "verified": true,
    "created_at": "2026-09-22T11:16:20.000Z",
    "updated_at": "2026-09-22T11:16:20.000Z",
    "contact_id": "22222222-0000-4000-8000-000000000001"
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/conversations

A contact’s conversations across every channel

Every thread this person has had, newest activity first. Each carries its lastMessage (the newest real message — activity pills are bookkeeping, not conversation), a last_message_at alias of last_message_date, its ticket, and its attributes.

meta.total is the contact’s FULL count, not the page size — use limit/offset to reach the rest. A contact with 221 threads used to report «total: 50».

This read is not narrowed by row-level visibility: the ficha shows a person’s whole history to anyone who may read the contact, which is what stops two people selling to the same customer twice.

ParameterInTypeRequiredConstraints
idpathuuidyes
limitqueryintegerno≥ 1, ≤ 100, por defecto 50
offsetqueryinteger | nullno≥ 0, por defecto 0
curl https://api.vitrinadev.com/api/v1/contacts/<id>/conversations \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "bbbbbbbb-0000-4000-8000-000000000001",
      "display_id": "C-3052",
      "channel": "whatsapp",
      "status": "open",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "assignee_user_id": "11111111-0000-4000-8000-000000000001",
      "last_message_date": "2026-09-21T16:41:02.000Z",
      "last_message_at": "2026-09-21T16:41:02.000Z",
      "lastMessage": {
        "id": "12121212-0000-4000-8000-000000000001",
        "content": "¿Sigue disponible?",
        "sender_type": "contact",
        "created_at": "2026-09-21T16:41:02.000Z"
      },
      "ticket": null,
      "attributes": []
    }
  ],
  "meta": {
    "total": 4,
    "limit": 25,
    "offset": 0
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/duplicates

Suggest duplicate contacts for one contact

Merge candidates for ONE named contact — live contacts matching its phone (last eight digits, so +56 9 1234 5678 and 912345678 meet), its email, or its exact name. A different question from GET /contacts/duplicates, which sweeps the whole book.

meta.primary_id echoes the contact you asked about, so a client rendering several of these at once cannot mix them up. A contact with no name, phone or email returns an empty list rather than matching everything.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/contacts/<id>/duplicates \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "22222222-0000-4000-8000-000000000002",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "external_id": null,
      "name": "María González",
      "email": null,
      "phone": "+56912345678",
      "language": "es",
      "country": "CL",
      "brand": null,
      "lifecycle_stage": "prospect",
      "job_title": "Gerente de operaciones",
      "city": "Providencia",
      "company_id": "15151515-0000-4000-8000-000000000001",
      "social": {
        "instagram": "@mariagonzalez"
      },
      "email_consent": false,
      "email_status": "subscribed",
      "avatar_url": null,
      "social_stats": {},
      "origin_channel": "manual",
      "merged_into_contact_id": null,
      "merged_at": null,
      "blocked_at": null,
      "archived_at": null,
      "spam_at": null,
      "bot_replies_disabled_at": null,
      "bot_replies_disabled_by": null,
      "created_at": "2026-09-21T14:03:11.000Z",
      "updated_at": "2026-09-21T14:03:11.000Z",
      "tax_id": null,
      "tax_id_kind": null,
      "person_kind": null,
      "legal_name": null,
      "giro": null,
      "representative_contact_id": null,
      "address_street": null,
      "address_number": null,
      "address_unit": null,
      "comuna_code": null,
      "region_code": null,
      "address_source": null,
      "display_name": "María González",
      "named": true
    }
  ],
  "meta": {
    "total": 1,
    "primary_id": "22222222-0000-4000-8000-000000000001"
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/merge

Merge other contacts into this one

Irreversible. There is no unmerge, here or in the app. The contact in the path SURVIVES; every id in secondary_ids becomes a tombstone.

What moves onto the survivor: conversations, channel identities, leads, tickets, notas de venta, credit applications, and any contact that named a secondary as its legal representative. What is FILLED, only where the survivor was empty: name, email, phone, language, country, brand, avatar, the per-channel social map, and the tax_id + tax_id_kind pair. A value the survivor already had is never overwritten. filled_fields names the columns that gained something — names, not values.

The tombstones keep answering GET /contacts/\{id\} with merged_into_contact_id set, so stored ids can be repointed.

Refused with a 400 that says why: an id from another workspace, an already-merged secondary, a survivor that is itself a tombstone, or a merge that would make the survivor its own legal representative (a persona jurídica and the person who signs for it are not the same party).

Emits contact.merged, whose notice carries the survivor as the resource and the tombstoned ids in changes.contact_id.from.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
secondary_idsuuid[]yes
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/merge \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "secondary_ids": [
      "22222222-0000-4000-8000-000000000002"
    ]
  }'

Example response (200)

{
  "data": {
    "primary": {
      "id": "22222222-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "external_id": null,
      "name": "María González",
      "email": "[email protected]",
      "phone": "+56912345678",
      "language": "es",
      "country": "CL",
      "brand": null,
      "lifecycle_stage": "prospect",
      "job_title": "Gerente de operaciones",
      "city": "Providencia",
      "company_id": "15151515-0000-4000-8000-000000000001",
      "social": {
        "instagram": "@mariagonzalez"
      },
      "email_consent": false,
      "email_status": "subscribed",
      "avatar_url": null,
      "social_stats": {},
      "origin_channel": "manual",
      "merged_into_contact_id": null,
      "merged_at": null,
      "blocked_at": null,
      "archived_at": null,
      "spam_at": null,
      "bot_replies_disabled_at": null,
      "bot_replies_disabled_by": null,
      "created_at": "2026-09-21T14:03:11.000Z",
      "updated_at": "2026-09-21T14:03:11.000Z",
      "tax_id": null,
      "tax_id_kind": null,
      "person_kind": null,
      "legal_name": null,
      "giro": null,
      "representative_contact_id": null,
      "address_street": null,
      "address_number": null,
      "address_unit": null,
      "comuna_code": null,
      "region_code": null,
      "address_source": null,
      "display_name": "María González",
      "named": true
    },
    "merged_count": 1,
    "conversations_reassigned": 3,
    "filled_fields": [
      "email"
    ]
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/notes

A contact’s internal notes

Free text a team leaves about a person, newest first. Internal: a note is never shown to the contact and is never sent anywhere. author_name is a snapshot taken when the note was written, so renaming a member does not rewrite their notes.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/contacts/<id>/notes \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "18181818-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "author_user_id": "11111111-0000-4000-8000-000000000001",
      "author_name": "Camila Rojas",
      "body": "Prefiere que la contacten por la tarde.",
      "created_at": "2026-09-22T11:17:44.000Z",
      "updated_at": "2026-09-22T11:17:44.000Z"
    }
  ],
  "meta": {
    "total": 1
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/notes

Add a note to a contact

Records one internal note. It appears in the contact’s timeline as a note row.

Notes are for what the team needs to remember about a person («prefiere que la contacten por la tarde»). Structured facts belong in a custom attribute, where they can be filtered and typed.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
bodystringyesmín. 1, máx. 8000
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/notes \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Prefiere que la contacten por la tarde."
  }'

Example response (201)

{
  "data": {
    "id": "18181818-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "author_user_id": "11111111-0000-4000-8000-000000000001",
    "author_name": "Camila Rojas",
    "body": "Prefiere que la contacten por la tarde.",
    "created_at": "2026-09-22T11:17:44.000Z",
    "updated_at": "2026-09-22T11:17:44.000Z"
  }
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

DELETE /contacts/{id}/notes/{noteId}

Delete a contact note

Removes the note for good, and with it its timeline row. There is no archive for a note.

ParameterInTypeRequiredConstraints
idpathuuidyes
noteIdpathuuidyes
curl -X DELETE https://api.vitrinadev.com/api/v1/contacts/<id>/notes/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429

PATCH /contacts/{id}/notes/{noteId}

Edit a contact note

Replaces the note’s body. The author snapshot and created_at are unchanged — an edit is not a new note, and the timeline keeps its original place.

ParameterInTypeRequiredConstraints
idpathuuidyes
noteIdpathuuidyes

Body

FieldTypeRequiredConstraints
bodystringyesmín. 1, máx. 8000
curl -X PATCH https://api.vitrinadev.com/api/v1/contacts/<id>/notes/<id> \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Prefiere que la contacten después de las 16:00."
  }'

Example response (200)

{
  "data": {
    "id": "18181818-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "author_user_id": "11111111-0000-4000-8000-000000000001",
    "author_name": "Camila Rojas",
    "body": "Prefiere que la contacten después de las 16:00.",
    "created_at": "2026-09-22T11:17:44.000Z",
    "updated_at": "2026-09-22T11:19:02.000Z"
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/outbound-preferences

A contact’s scope-aware consent / do-not-contact ledger

Append-only history, newest fact first — this is the surface that can answer «since when, and how do you know».

Each row is one fact about one channel (whatsapp|email|voice|any) in one scope (marketing|service|promised_followup|all_proactive). A marketing block does NOT block service messages; a current all_proactive block outranks every scope.

A superseded row stays readable rather than being overwritten, so the evidence trail survives the revocation.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/contacts/<id>/outbound-preferences \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "19191919-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "channel": "email",
      "scope": "marketing",
      "status": "blocked",
      "source": "admin",
      "evidence_message_id": null,
      "legal_basis": "Pidió por escrito no recibir promociones",
      "recorded_at": "2026-09-22T11:20:01.000Z",
      "expires_at": null,
      "created_by": "11111111-0000-4000-8000-000000000001",
      "created_at": "2026-09-22T11:20:01.000Z"
    }
  ],
  "meta": {
    "total": 1
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/outbound-preferences

Record an outbound preference (consent or DNC) for a contact

Appends one fact. Revoking is a POST with the opposite status — the superseded row stays readable as evidence, which is why there is no PATCH or DELETE here.

recorded_at is stamped by the server and is deliberately not settable: a caller who could backdate a consent fact could manufacture evidence. legal_basis is free text recorded verbatim («consentimiento explícito, Ley 21.719») and evidence_message_id points at the customer message that proves it, when there is one.

A blocked fact here is enforced by the política de envíos: a later send to this contact in that scope is refused with a Bloqueo, not silently dropped.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
channelwhatsapp \email \voice \
scopemarketing \service \promised_followup \
statusallowed \blocked \unknown
legal_basisstring | nullmáx. 200
evidence_message_idstring | null
expires_atstring | nulldate-time
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/outbound-preferences \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "scope": "marketing",
    "status": "blocked",
    "legal_basis": "Pidió por escrito no recibir promociones"
  }'

Example response (201)

{
  "data": {
    "id": "19191919-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "channel": "email",
    "scope": "marketing",
    "status": "blocked",
    "source": "admin",
    "evidence_message_id": null,
    "legal_basis": "Pidió por escrito no recibir promociones",
    "recorded_at": "2026-09-22T11:20:01.000Z",
    "expires_at": null,
    "created_by": "11111111-0000-4000-8000-000000000001",
    "created_at": "2026-09-22T11:20:01.000Z"
  }
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/report-spam

Flag or unflag a contact as spam

Same consequences as block — spam_at is stamped and lifecycle_stage becomes blocked, reset to unknown when cleared — with a different reason recorded, which is what a workspace reviewing its filtered traffic later needs to tell the two apart.

Emits contact.updated.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
valuebooleanyes
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/report-spam \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "value": true
  }'

Example response (200)

{
  "data": {
    "id": "22222222-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "language": "es",
    "country": "CL",
    "brand": null,
    "lifecycle_stage": "blocked",
    "job_title": "Gerente de operaciones",
    "city": "Providencia",
    "company_id": "15151515-0000-4000-8000-000000000001",
    "social": {
      "instagram": "@mariagonzalez"
    },
    "email_consent": false,
    "email_status": "subscribed",
    "avatar_url": null,
    "social_stats": {},
    "origin_channel": "manual",
    "merged_into_contact_id": null,
    "merged_at": null,
    "blocked_at": "2026-09-22T11:24:02.000Z",
    "archived_at": null,
    "spam_at": null,
    "bot_replies_disabled_at": null,
    "bot_replies_disabled_by": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "updated_at": "2026-09-22T11:24:02.000Z",
    "tax_id": null,
    "tax_id_kind": null,
    "person_kind": null,
    "legal_name": null,
    "giro": null,
    "representative_contact_id": null,
    "address_street": null,
    "address_number": null,
    "address_unit": null,
    "comuna_code": null,
    "region_code": null,
    "address_source": null,
    "display_name": "María González",
    "named": true
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/tags

Tags on a contact

The tags attached to one contact, with a total. These are the same tags /tags manages and the same ones conversations use — one taxonomy, several things it can be attached to. name is the slug and display_name is what a person typed.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/contacts/<id>/tags \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "17171717-0000-4000-8000-000000000002",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "tag_id": "17171717-0000-4000-8000-000000000001",
      "name": "vip",
      "display_name": "VIP",
      "created_at": "2026-09-22T11:15:03.000Z"
    }
  ],
  "meta": {
    "total": 1
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/{id}/tags

Tag a contact

Send exactly one of tag_id (attach an existing tag) or name (resolve by slug, creating the tag if it is new). Sending both, or neither, is a 400.

Idempotent — attaching a tag the contact already has changes nothing. Answers 201 with the contact’s complete tag list, not just the tag added, so there is no need to re-read.

Watch the by-name form: because it creates, a typo silently adds a new tag to the workspace rather than failing. Pass tag_id where the tag is meant to be known.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
tag_iduuid
namestringmín. 1, máx. 60
curl -X POST https://api.vitrinadev.com/api/v1/contacts/<id>/tags \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "vip"
  }'

Example response (201)

{
  "data": [
    {
      "id": "17171717-0000-4000-8000-000000000002",
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "tag_id": "17171717-0000-4000-8000-000000000001",
      "name": "vip",
      "display_name": "VIP",
      "created_at": "2026-09-22T11:15:03.000Z"
    }
  ]
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

DELETE /contacts/{id}/tags/{tagId}

Untag a contact

Removes the attachment only — the tag stays in the workspace and on every other contact. Removing one that was not attached is a 404, so this call does confirm something was removed. To delete the tag itself, use DELETE /tags/\{id\}.

ParameterInTypeRequiredConstraints
idpathuuidyes
tagIdpathuuidyes
curl -X DELETE https://api.vitrinadev.com/api/v1/contacts/<id>/tags/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/{id}/timeline

One chronological feed of everything that happened to a contact

Merges nine families that already exist — conversation, lead, lead_activity, appointment, note (contact notes), recontacto (outbound attempts), hold (safety holds), ticket and audit (operator actions on this contact) — into one feed, newest first.

There is no contact-event table and this does not add one. Every row is read from the table that already owns it, so nothing has to be backfilled and no second write path can drift.

kind is a comma-separated list (kind=conversation,lead,appointment) and only the requested families are queried — it is a narrowing of the read, not a filter over the whole union. An unrecognised token is a 400 naming it.

title is deliberately key-ish where the source is: a lead_activity title is its raw kind (interest_changed), which the client maps to a label. ref_id is the record you can open — for lead_activity that is the LEAD, since the activity’s own id is a bigint nothing links to. data carries lead_activity.data whole and is null for every other kind.

actor.kind is person (a resolved teammate), ai_agent, api (an integration credential) or system. It is derived from what the row stores — a user id, the author snapshot a history row keeps (ADR 0106 §3.1), or a token such as conversation.handler / lead.source / audit_log.principal_kind — never guessed. An api actor carries the API key id and its name as it was when it acted («CRM (API)»); a person who acted through a connected app or a personal token carries actor.via \{ kind, name \} («Camila vía Claude»).

meta.has_more comes from reading one row past the page, so it is exact even when the total is a multiple of limit.

ParameterInTypeRequiredConstraints
idpathuuidyes
limitqueryintegerno≥ 1, ≤ 100, por defecto 50
offsetqueryinteger | nullno≥ 0, por defecto 0
kindquerystringnomín. 1, máx. 200
curl https://api.vitrinadev.com/api/v1/contacts/<id>/timeline \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "ts": "2026-09-22T11:20:01.000Z",
      "kind": "audit",
      "title": "contacts.outbound_preferences.record",
      "detail": null,
      "ref_id": "21212121-0000-4000-8000-000000000001",
      "display_id": null,
      "status": null,
      "channel": null,
      "actor": {
        "kind": "api",
        "id": "c1c1c1c1-0000-4000-8000-000000000001",
        "name": "CRM (API)"
      },
      "data": null
    },
    {
      "ts": "2026-09-22T11:17:44.000Z",
      "kind": "note",
      "title": "note",
      "detail": "Prefiere que la contacten por la tarde.",
      "ref_id": "18181818-0000-4000-8000-000000000001",
      "display_id": null,
      "status": null,
      "channel": null,
      "actor": {
        "kind": "person",
        "id": "11111111-0000-4000-8000-000000000001",
        "name": "Camila Rojas"
      },
      "data": null
    },
    {
      "ts": "2026-09-21T16:40:12.000Z",
      "kind": "conversation",
      "title": "whatsapp · open",
      "detail": null,
      "ref_id": "bbbbbbbb-0000-4000-8000-000000000001",
      "display_id": "C-3052",
      "status": "open",
      "channel": "whatsapp",
      "actor": {
        "kind": "ai_agent",
        "id": null,
        "name": null
      },
      "data": null
    }
  ],
  "meta": {
    "limit": 50,
    "offset": 0,
    "has_more": true
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/bulk-consent

Record email-marketing consent for many contacts

Flips email_consent to true for up to 1.000 contacts and answers \{ flipped, marketable \} — how many actually changed, and the workspace’s new marketable total.

flipped is normally lower than the number of ids sent, and that is not an error: contacts that already consented, and contacts with no email address, are skipped. Do not treat a shortfall as a partial failure.

This endpoint RECORDS an assertion; it does not obtain consent. Nothing here captures when, how or on what wording the contact opted in — unlike the outreach-consent path, which stamps a server-side timestamp precisely because it has to serve as evidence. The operator calling this is attesting that consent exists and remains responsible for proving it.

It is one-way and it changes the bill. There is no bulk revoke here (a single contact is revoked through POST /contacts/\{id\}/outbound-preferences), and the marketable count this raises is the billed unit of the email rate card. A contact is marketable when it has a valid email address, email_consent is true, email_status is subscribed (not unsubscribed, bounced or complained), and it is not merged, archived or blocked. All four conditions, not just consent.

Registered before /\{id\} so the literal path is not read as an id.

Body

FieldTypeRequiredConstraints
contact_idsuuid[]yes
curl -X POST https://api.vitrinadev.com/api/v1/contacts/bulk-consent \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_ids": [
      "22222222-0000-4000-8000-000000000001",
      "22222222-0000-4000-8000-000000000002"
    ]
  }'

Example response (200)

{
  "data": {
    "flipped": 1,
    "marketable": 62
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/duplicates

Duplicate contact clusters (live contacts only)

Groups of live contacts sharing a phone (last ten digits, minimum eight) or a lowercased email, over the whole book rather than one page. A phone group and an email group covering the same people are reported once, as the phone one. Ordered biggest first and capped at 100 clusters; meta.total is the real cluster count, so a client can tell «these are all of them» from «these are the worst».

Each cluster carries its members in full, so a merge screen needs no second read. Feed the survivor and the rest to POST /contacts/\{id\}/merge.

curl https://api.vitrinadev.com/api/v1/contacts/duplicates \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "phone:56912345678",
      "reason": "phone",
      "value": "56912345678",
      "contacts": [
        {
          "id": "22222222-0000-4000-8000-000000000001",
          "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
          "external_id": null,
          "name": "María González",
          "email": "[email protected]",
          "phone": "+56912345678",
          "language": "es",
          "country": "CL",
          "brand": null,
          "lifecycle_stage": "prospect",
          "job_title": "Gerente de operaciones",
          "city": "Providencia",
          "company_id": "15151515-0000-4000-8000-000000000001",
          "social": {
            "instagram": "@mariagonzalez"
          },
          "email_consent": false,
          "email_status": "subscribed",
          "avatar_url": null,
          "social_stats": {},
          "origin_channel": "manual",
          "merged_into_contact_id": null,
          "merged_at": null,
          "blocked_at": null,
          "archived_at": null,
          "spam_at": null,
          "bot_replies_disabled_at": null,
          "bot_replies_disabled_by": null,
          "created_at": "2026-09-21T14:03:11.000Z",
          "updated_at": "2026-09-21T14:03:11.000Z",
          "tax_id": null,
          "tax_id_kind": null,
          "person_kind": null,
          "legal_name": null,
          "giro": null,
          "representative_contact_id": null,
          "address_street": null,
          "address_number": null,
          "address_unit": null,
          "comuna_code": null,
          "region_code": null,
          "address_source": null,
          "display_name": "María González",
          "named": true,
          "channels": [
            "whatsapp"
          ],
          "lead_sources": [
            "website"
          ]
        },
        {
          "id": "22222222-0000-4000-8000-000000000002",
          "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
          "external_id": null,
          "name": "María González",
          "email": null,
          "phone": "+56912345678",
          "language": "es",
          "country": "CL",
          "brand": null,
          "lifecycle_stage": "prospect",
          "job_title": "Gerente de operaciones",
          "city": "Providencia",
          "company_id": null,
          "social": {
            "instagram": "@mariagonzalez"
          },
          "email_consent": false,
          "email_status": "subscribed",
          "avatar_url": null,
          "social_stats": {},
          "origin_channel": "manual",
          "merged_into_contact_id": null,
          "merged_at": null,
          "blocked_at": null,
          "archived_at": null,
          "spam_at": null,
          "bot_replies_disabled_at": null,
          "bot_replies_disabled_by": null,
          "created_at": "2026-09-21T14:03:11.000Z",
          "updated_at": "2026-09-21T14:03:11.000Z",
          "tax_id": null,
          "tax_id_kind": null,
          "person_kind": null,
          "legal_name": null,
          "giro": null,
          "representative_contact_id": null,
          "address_street": null,
          "address_number": null,
          "address_unit": null,
          "comuna_code": null,
          "region_code": null,
          "address_source": null,
          "display_name": "María González",
          "named": true,
          "channels": [],
          "lead_sources": []
        }
      ]
    }
  ],
  "meta": {
    "total": 1
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/export

Export contacts as a CSV file

Streams the whole directory as UTF-8 CSV (with a BOM, so spreadsheets open the accents correctly), narrowed by the same q and lifecycle_stage the search takes. It is paged internally by keyset, so a large book is neither buffered nor truncated — there is no limit to get wrong.

The columns are the importable ones, so an export can be edited and fed straight back to POST /contacts/import. The legal identity and the structured address are NOT among them.

Answers text/csv with a Content-Disposition filename, not JSON.

ParameterInTypeRequiredConstraints
lifecycle_stagequeryunknown \prospect \qualified_prospect \
qquerystringnomáx. 200
curl https://api.vitrinadev.com/api/v1/contacts/export \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/import

Import contacts (dry_run returns a preview without writing)

Send exactly one of csv (raw text, RFC 4180, with an optional mapping from header to field) or rows (already mapped, up to 5.000 per call). Sending both, or neither, is a 400.

This is the deduplicating way in. Each row is matched against the workspace on external_id, then email, then phone. A match is UPDATED — blank fields filled, never clobbered — and anything else is created. Two rows of the same file with the same key: the second is skipped, not inserted again.

dry_run: true runs the whole pipeline, including the real dedup lookup, and writes nothing. The report it returns is the same shape as the committed one, so a client can show «X new, Y updates, Z skipped, N errors» and let a person confirm.

email_consent_attested: true is the ONLY way an import turns email_consent on, and it is an assertion the caller makes about every row in the file: that these people opted in. The response then carries consent_gained and marketable_after — it changes the bill. A contact is marketable when it has a valid email address, email_consent is true, email_status is subscribed (not unsubscribed, bounced or complained), and it is not merged, archived or blocked. All four conditions, not just consent.

A bad row is reported in rows[] with its line number and reason; it does not fail the batch. The importer deliberately does not fire per-contact automations, but it DOES emit one contact.created per row it created and one contact.updated per row it matched.

Body

FieldTypeRequiredConstraints
csvstringmín. 1, máx. 10000000
rowsobject[]
mappingobject
dry_runboolean
email_consent_attestedboolean
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
  }'

Example response (200)

{
  "data": {
    "total": 1,
    "created": 1,
    "updated": 0,
    "skipped": 0,
    "failed": 0,
    "dry_run": true,
    "rows": [
      {
        "row": 1,
        "action": "create",
        "name": "Pedro Ramírez"
      }
    ]
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/marketable-count

How many contacts email marketing may reach

Answers \{ marketable \}. A contact is marketable when it has a valid email address, email_consent is true, email_status is subscribed (not unsubscribed, bounced or complained), and it is not merged, archived or blocked. All four conditions, not just consent.

This is the billed figure for the email rate card — a live snapshot of the contact base, not a windowed count, so it moves as contacts consent, unsubscribe or bounce. Show it before a consent import if the customer should see the cost implication.

Zero is the expected answer for a workspace that has never captured consent: the flag defaults to false and was deliberately never backfilled, so an established workspace with thousands of contacts can legitimately report 0.

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

Example response (200)

{
  "data": {
    "marketable": 62
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

Search contacts (paged; meta.total is the match count, not the page size)

The directory read. q matches across name, email, phone and external_id at once, case- and accent-insensitively («Jose» finds «José»).

meta.total is the number of MATCHES, not the page size. It used to be the latter, which made it a restatement of limit — a client that asked for 1.000 was told «total: 1.000» and drew a book that stopped there.

lifecycle_stage accepts one stage or a comma-separated list — lifecycle_stage=unknown,prospect,qualified_prospect is how the app asks for its «Prospectos» bucket. Every token is validated against unknown|prospect|qualified_prospect|customer|repeat_customer|inactive|blocked; an unrecognised one is a 400 rather than a filter that matches nobody.

Rows carry two badge arrays the stored contact does not: channels (the distinct contact_channel kinds) and lead_sources (the distinct lead.source values). lead_sources outranks origin_channel — provenance derived from real leads beats the one an operator typed, so origin_channel is only the answer when lead_sources is empty.

The filters combine and every one of them is optional: lifecycle_stage, channel (a linked channel of that kind), lead_source, tag_id, company_id (a uuid, or the literal none for the contacts that belong to no company) and exclude_id.

Merged contacts are excluded unless include_merged=true. display_name is never empty and named says whether it IS the person’s name: when a contact has none, display_name falls back to a formatted phone, an email or a channel handle and named is false. Check named before putting display_name into text a customer reads — otherwise a template greets somebody by their own phone number.

ParameterInTypeRequiredConstraints
qquerystringnomáx. 200
exclude_idqueryuuidno
include_mergedqueryanyno
limitqueryintegerno≥ 1, ≤ 1000, por defecto 25
offsetqueryinteger | nullno≥ 0, por defecto 0
lifecycle_stagequerystringnomín. 1, máx. 200
channelquerystringnomín. 1, máx. 40
lead_sourcequerystringnomín. 1, máx. 60
tag_idqueryuuidno
company_idqueryanyno
curl https://api.vitrinadev.com/api/v1/contacts/search \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "22222222-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "external_id": null,
      "name": "María González",
      "email": "[email protected]",
      "phone": "+56912345678",
      "language": "es",
      "country": "CL",
      "brand": null,
      "lifecycle_stage": "prospect",
      "job_title": "Gerente de operaciones",
      "city": "Providencia",
      "company_id": "15151515-0000-4000-8000-000000000001",
      "social": {
        "instagram": "@mariagonzalez"
      },
      "email_consent": false,
      "email_status": "subscribed",
      "avatar_url": null,
      "social_stats": {},
      "origin_channel": "manual",
      "merged_into_contact_id": null,
      "merged_at": null,
      "blocked_at": null,
      "archived_at": null,
      "spam_at": null,
      "bot_replies_disabled_at": null,
      "bot_replies_disabled_by": null,
      "created_at": "2026-09-21T14:03:11.000Z",
      "updated_at": "2026-09-21T14:03:11.000Z",
      "tax_id": null,
      "tax_id_kind": null,
      "person_kind": null,
      "legal_name": null,
      "giro": null,
      "representative_contact_id": null,
      "address_street": null,
      "address_number": null,
      "address_unit": null,
      "comuna_code": null,
      "region_code": null,
      "address_source": null,
      "display_name": "María González",
      "named": true,
      "channels": [
        "whatsapp"
      ],
      "lead_sources": [
        "website"
      ]
    }
  ],
  "meta": {
    "total": 148,
    "limit": 25,
    "offset": 0
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /contacts/stats

Contact directory facet counts (live contacts only)

The denominators behind the directory’s filter chips, computed over the whole book rather than the loaded page: totals by lifecycle stage, by channel, by lead source, plus how many contacts are reachable by email and by phone and how many duplicate clusters exist.

Merged and archived contacts are excluded — this counts the live directory, which is what every number a client renders next to a filter has to agree with.

curl https://api.vitrinadev.com/api/v1/contacts/stats \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "total": 148,
    "by_lifecycle": {
      "unknown": 96,
      "prospect": 31,
      "customer": 21
    },
    "by_channel": {
      "whatsapp": 88,
      "instagram": 12,
      "email": 7
    },
    "by_lead_source": {
      "website": 44,
      "conversation": 31,
      "import": 12,
      "manual": 9
    },
    "email_reachable": 61,
    "phone_reachable": 132,
    "duplicate_candidates": 3
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /contacts/tags/bulk

Tag many contacts at once

Applies one tag to up to 500 contacts and answers \{ tagged, tag_id \}. Send either tag.tag_id for an existing tag or tag.name to resolve-or-create by slug — exactly one, not both.

tagged counts the attachments actually made, so contacts that already carried the tag are not counted again. Ids the workspace does not own contribute nothing and do not fail the call, so this is not a way to check which ids are valid.

Registered before /\{id\} so tags is never read as a contact id.

Body

FieldTypeRequiredConstraints
contact_idsuuid[]yes
tagobjectyes
curl -X POST https://api.vitrinadev.com/api/v1/contacts/tags/bulk \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_ids": [
      "22222222-0000-4000-8000-000000000001",
      "22222222-0000-4000-8000-000000000002"
    ],
    "tag": {
      "name": "campaña-primavera"
    }
  }'

Example response (200)

{
  "data": {
    "tagged": 2,
    "tag_id": "17171717-0000-4000-8000-000000000001"
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

On this page