Contactos
Las personas con las que habla el workspace: buscar, crear, corregir, fusionar, etiquetar, y los canales y atributos que cuelgan de cada una.
Descarga la proyección completa de la API pública: openapi.json.
Buscar, crear y fusionar contactos explica este recurso en prosa, con ejemplos ejecutables.
| Método | Ruta | Qué hace |
|---|---|---|
POST | /contacts | Create a contact |
GET | /contacts/{id} | Get a contact |
PATCH | /contacts/{id} | Update a contact |
POST | /contacts/{id}/archive | Archive or unarchive a contact |
GET | /contacts/{id}/attachments | Files exchanged with a contact across every conversation |
GET | /contacts/{id}/attributes | Custom attributes on a contact, merged with their definitions |
PUT | /contacts/{id}/attributes | Upsert custom attributes on a contact |
DELETE | /contacts/{id}/attributes/{key} | Delete one custom attribute from a contact |
GET | /contacts/{id}/attributes/{key}/file | Download a contact’s stored attribute file |
POST | /contacts/{id}/attributes/{key}/file | Upload a file into a contact’s file attribute |
POST | /contacts/{id}/block | Block or unblock a contact |
POST | /contacts/{id}/bot-replies-disabled | Turn the AI off (or back on) for one contact, on every channel |
GET | /contacts/{id}/channels | A contact’s linked channel identities |
POST | /contacts/{id}/channels | Link 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}/conversations | A contact’s conversations across every channel |
GET | /contacts/{id}/duplicates | Suggest duplicate contacts for one contact |
POST | /contacts/{id}/merge | Merge other contacts into this one |
GET | /contacts/{id}/notes | A contact’s internal notes |
POST | /contacts/{id}/notes | Add 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-preferences | A contact’s scope-aware consent / do-not-contact ledger |
POST | /contacts/{id}/outbound-preferences | Record an outbound preference (consent or DNC) for a contact |
POST | /contacts/{id}/report-spam | Flag or unflag a contact as spam |
GET | /contacts/{id}/tags | Tags on a contact |
POST | /contacts/{id}/tags | Tag a contact |
DELETE | /contacts/{id}/tags/{tagId} | Untag a contact |
GET | /contacts/{id}/timeline | One chronological feed of everything that happened to a contact |
POST | /contacts/bulk-consent | Record email-marketing consent for many contacts |
GET | /contacts/duplicates | Duplicate contact clusters (live contacts only) |
GET | /contacts/export | Export contacts as a CSV file |
POST | /contacts/import | Import contacts (dry_run returns a preview without writing) |
GET | /contacts/marketable-count | How many contacts email marketing may reach |
GET | /contacts/search | Search contacts (paged; meta.total is the match count, not the page size) |
GET | /contacts/stats | Contact directory facet counts (live contacts only) |
POST | /contacts/tags/bulk | Tag 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.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
name | string | sí | mín. 1, máx. 200 |
email | string | null | — | email, máx. 254 |
phone | string | null | — | mín. 1, máx. 50 |
language | string | null | — | mín. 2, máx. 10 |
country | string | null | — | mín. 2, máx. 10 |
brand | string | null | — | mín. 1, máx. 120 |
lifecycle_stage | unknown \ | prospect \ | qualified_prospect \ |
job_title | string | null | — | máx. 200 |
city | string | null | — | máx. 200 |
company_id | string | null | — | |
social | object | null | — | |
origin_channel | conversation \ | marketplace \ | manual \ |
tax_id | string | null | — | mín. 1, máx. 64 |
tax_id_kind | rut \ | passport \ | foreign_tax_id |
person_kind | natural \ | juridica | — |
legal_name | string | null | — | máx. 300 |
giro | string | null | — | máx. 300 |
representative_contact_id | string | null | — | |
address_street | string | null | — | máx. 200 |
address_number | string | null | — | máx. 40 |
address_unit | string | null | — | máx. 60 |
comuna_code | string | null | — | patró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"
}'Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/contacts/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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"
]
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
email_consent | boolean | — | |
name | string | null | — | mín. 1, máx. 200 |
email | string | null | — | email, máx. 254 |
phone | string | null | — | mín. 1, máx. 50 |
language | string | null | — | mín. 2, máx. 10 |
country | string | null | — | mín. 2, máx. 10 |
brand | string | null | — | mín. 1, máx. 120 |
lifecycle_stage | unknown \ | prospect \ | qualified_prospect \ |
job_title | string | null | — | máx. 200 |
city | string | null | — | máx. 200 |
company_id | string | null | — | |
social | object | null | — | |
origin_channel | conversation \ | marketplace \ | manual \ |
tax_id | string | null | — | mín. 1, máx. 64 |
tax_id_kind | rut \ | passport \ | foreign_tax_id |
person_kind | natural \ | juridica | — |
legal_name | string | null | — | máx. 300 |
giro | string | null | — | máx. 300 |
representative_contact_id | string | null | — | |
address_street | string | null | — | máx. 200 |
address_number | string | null | — | máx. 40 |
address_unit | string | null | — | máx. 60 |
comuna_code | string | null | — | patró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"
}'Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
value | boolean | sí |
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
}'Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
limit | query | integer | no | ≥ 1, ≤ 100, por defecto 40 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/attachments \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/attributes \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
]
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
attributes | object[] | sí |
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"
}
]
}'Ejemplo de respuesta (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
}
]
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
key | path | string | sí | mí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"Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
key | path | string | sí | mí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"Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | string | sí | mín. 1 |
key | path | string | sí | mí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"Ejemplo de respuesta (201)
{
"data": {
"key": "contrato_firmado",
"filename": "contrato.pdf",
"mime": "application/pdf",
"size": 182334,
"uploaded_at": "2026-09-22T11:22:10.000Z"
}
}Responde: 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 unknown — the 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
value | boolean | sí |
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
}'Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
value | boolean | sí |
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
}'Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/channels \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
kind | email \ | phone \ | whatsapp \ |
identifier | string | sí | mín. 1, máx. 254 |
label | string | null | — | máx. 120 |
verified | boolean | — |
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
}'Ejemplo de respuesta (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"
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
channelId | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/contacts/<id>/channels/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
channelId | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
verified | boolean | sí |
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
}'Ejemplo de respuesta (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"
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
limit | query | integer | no | ≥ 1, ≤ 100, por defecto 50 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/conversations \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/duplicates \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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"
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
secondary_ids | uuid[] | sí |
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"
]
}'Ejemplo de respuesta (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"
]
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/notes \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
body | string | sí | mí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."
}'Ejemplo de respuesta (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"
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
noteId | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/contacts/<id>/notes/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
noteId | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
body | string | sí | mí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."
}'Ejemplo de respuesta (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"
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/outbound-preferences \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
channel | whatsapp \ | email \ | voice \ |
scope | marketing \ | service \ | promised_followup \ |
status | allowed \ | blocked \ | unknown |
legal_basis | string | null | — | máx. 200 |
evidence_message_id | string | null | — | |
expires_at | string | null | — | date-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"
}'Ejemplo de respuesta (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"
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
value | boolean | sí |
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
}'Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/tags \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
tag_id | uuid | — | |
name | string | — | mí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"
}'Ejemplo de respuesta (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"
}
]
}Responde: 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\}.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
tagId | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/contacts/<id>/tags/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
limit | query | integer | no | ≥ 1, ≤ 100, por defecto 50 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
kind | query | string | no | mín. 1, máx. 200 |
curl https://api.vitrinadev.com/api/v1/contacts/<id>/timeline \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
contact_ids | uuid[] | sí |
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"
]
}'Ejemplo de respuesta (200)
{
"data": {
"flipped": 1,
"marketable": 62
}
}Responde: 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"Ejemplo de respuesta (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
}
}Responde: 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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
lifecycle_stage | query | unknown \ | prospect \ | qualified_prospect \ |
q | query | string | no | máx. 200 |
curl https://api.vitrinadev.com/api/v1/contacts/export \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 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.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
csv | string | — | mín. 1, máx. 10000000 |
rows | object[] | — | |
mapping | object | — | |
dry_run | boolean | — | |
email_consent_attested | boolean | — |
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
}'Ejemplo de respuesta (200)
{
"data": {
"total": 1,
"created": 1,
"updated": 0,
"skipped": 0,
"failed": 0,
"dry_run": true,
"rows": [
{
"row": 1,
"action": "create",
"name": "Pedro Ramírez"
}
]
}
}Responde: 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"Ejemplo de respuesta (200)
{
"data": {
"marketable": 62
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /contacts/search
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.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
q | query | string | no | máx. 200 |
exclude_id | query | uuid | no | |
include_merged | query | any | no | |
limit | query | integer | no | ≥ 1, ≤ 1000, por defecto 25 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
lifecycle_stage | query | string | no | mín. 1, máx. 200 |
channel | query | string | no | mín. 1, máx. 40 |
lead_source | query | string | no | mín. 1, máx. 60 |
tag_id | query | uuid | no | |
company_id | query | any | no |
curl https://api.vitrinadev.com/api/v1/contacts/search \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (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
}
}Responde: 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"Ejemplo de respuesta (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
}
}Responde: 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.
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
contact_ids | uuid[] | sí | |
tag | object | sí |
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"
}
}'Ejemplo de respuesta (200)
{
"data": {
"tagged": 2,
"tag_id": "17171717-0000-4000-8000-000000000001"
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429