API REST de Bodezy CRM — Documentación para desarrolladores - BODEZY

API REST de Bodezy CRM

Conecta tu ERP, tu tienda en línea o cualquier sistema con tu CRM: da de alta clientes, consulta conversaciones y envía mensajes de WhatsApp desde tu propio software.

REST sobre HTTPSJSONAutenticación por llaveVersión v1

Inicio rápido

Tres pasos y estás enviando tu primer mensaje desde tu sistema.

1

Crea tu llave

Entra a tu CRM → Ajustes → API para desarrolladores, pulsa Crear llave y marca qué permisos tendrá. La llave se muestra una sola vez: guárdala bien.

2

Comprueba que sirve

Llama a /me. Si responde con el nombre de tu cuenta, ya estás dentro.

3

Intégrala

Crea contactos desde tu ERP, consulta el embudo o envía un WhatsApp con una sola petición.

Tu primera llamada

curl -s 'https://crm.bodezy.com/api/v1/me' 
  -H 'Authorization: Bearer TU_LLAVE'

Autenticación

Toda petición viaja con tu llave en la cabecera Authorization. No hay sesiones ni cookies: cada llamada se autentica sola.

Authorization: Bearer bzy_live_xxxxxxxxxxxxxxxxxxxx
Tu llave es como tu contraseña. Guárdala en el servidor, nunca en el navegador ni en una app móvil, y jamás la subas a un repositorio. Si se te escapa, revócala desde el CRM y crea otra: el corte es inmediato.

Permisos

Leer siempre está permitido. Lo que modifica o gasta se concede al crear la llave:

Permiso Qué habilita
messages:send Enviar mensajes. Usa tu número real de WhatsApp.
contacts:write Crear y actualizar contactos y sus etiquetas.
pipeline:write Cambiar etapa del embudo, asignar agente y estado.

Alcance

Una llave puede abarcar toda tu cuenta o limitarse a un solo espacio. Si vas a dársela a un proveedor externo, limítala.

La API está incluida en los planes Profesional y Avanzado. Sólo responde sobre espacios cuyo plan la incluya; si un espacio está en otro plan, sus datos no se exponen aunque la llave abarque toda la cuenta.

Convenciones

URL base

https://crm.bodezy.com/api/v1

Siempre HTTPS. La versión va en la ruta: nunca romperemos v1.

Identificadores

Todos los id viajan como texto, no como número. Trátalos como cadenas: son demasiado grandes para el tipo numérico de JavaScript y perderías precisión.

Paginación

Los listados usan cursor, no páginas numeradas: así no se repiten ni se saltan registros aunque entren mensajes nuevos mientras recorres.

GET /api/v1/contacts?limit=50
→ { "data": [...], "has_more": true, "next_cursor": "MjAyNi0wNy0y..." }

GET /api/v1/contacts?limit=50&cursor=MjAyNi0wNy0y...

limit por defecto 50, máximo 100. Cuando has_more es false, terminaste.

Errores

Siempre la misma forma, con un code estable pensado para que tu programa lo entienda:

{ "error": { "code": "insufficient_scope",
             "message": "Esta llave no tiene el permiso "messages:send".",
             "required_scope": "messages:send" } }

Verificar la llave

GET
/me
solo lectura

Devuelve la identidad de la llave de API: la cuenta (inquilino) a la que pertenece, los espacios que puede tocar con sus nombres, los permisos concedidos y los límites de tasa. Es el endpoint de verificación: si responde 200, la llave sirve; el arreglo `spaces` es exactamente el universo de datos que verán todos los demás endpoints. Un 401 significa llave inválida/revocada/caducada y un 403 con código `plan_upgrade_required` significa que la llave es real pero ningún espacio de la cuenta tiene un plan con API activo.

Parámetros

(ninguno) no acepta parámetros de query ni cuerpo.
Authorization: Bearer <llave> (cabecera obligatoria)

Ejemplo

curl -s https://crm.bodezy.com/api/v1/me 
  -H "Authorization: Bearer bzy_live_8Kd2mWqPzR7nVxLtY4bHcJfA"
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/me');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/me',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/me', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/me");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "account": {
    "id": "12",
    "name": "Muebles del Norte SA de CV"
  },
  "spaces": [
    { "id": "2", "name": "Ventas", "archived": false },
    { "id": "5", "name": "Soporte", "archived": false },
    { "id": "9", "name": "Campaña Verano 2025", "archived": true }
  ],
  "scopes": ["contacts:write", "messages:send"],
  "token": {
    "id": "31",
    "space_id": null
  },
  "rate_limit": {
    "limit": 120,
    "window_seconds": 60
  }
}

// Cabeceras de la respuesta:
//   X-RateLimit-Limit: 120
//   X-RateLimit-Window: 60

// Ejemplo de error (llave revocada), HTTP 401:
// {"error":{"code":"token_revoked","message":"Esta llave de API fue revocada."}}

// Ejemplo de error (sin plan con API), HTTP 403:
// {"error":{"code":"plan_upgrade_required","message":"La API REST está incluida en los planes Profesional y Avanzado. Ningún espacio de esta cuenta con acceso a la API está activo."}}

Contactos

GET
/contacts
solo lectura

Lista los contactos de los espacios que alcanza la llave, del más nuevo al más viejo, paginados por cursor. Sirve tanto para volcar el directorio completo como para sincronizar sólo lo que cambió (updated_since) o para resolver un contacto concreto por teléfono o por su id en el ERP (external_id).

Parámetros

space_id (opcional) restringe a UN espacio. Debe estar dentro del alcance de la llave o responde 400 (no se ignora en silencio).
phone (opcional) búsqueda EXACTA. Se normaliza a dígitos, así que "+52 55 1234 5678" y "5215512345678" encuentran lo mismo.
external_id (opcional) busca por custom->>'external_id'; es el enlace con el cliente del ERP.
q (opcional) búsqueda parcial (ilike) sobre nombre, teléfono y email.
updated_since (opcional) ISO 8601; devuelve lo modificado desde esa fecha inclusive. Para sincronización incremental.
limit (opcional) por defecto 50, máximo 100.
cursor (opcional) el next_cursor de la respuesta anterior.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/contacts?space_id=2&q=maria&limit=25' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'

# Página siguiente
curl -s 'https://crm.bodezy.com/api/v1/contacts?space_id=2&q=maria&limit=25&cursor=MjAyNi0wNy0xOVQxODowNDoxMS4yMjFafDQ4MjEz' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'

# Enlazar con el ERP: ¿ya tengo el cliente ERP-1042?
curl -s 'https://crm.bodezy.com/api/v1/contacts?external_id=ERP-1042' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'

# Sincronización incremental
curl -s 'https://crm.bodezy.com/api/v1/contacts?updated_since=2026-07-19T00:00:00Z' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/contacts?space_id=2&q=maria&limit=25');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/contacts?space_id=2&q=maria&limit=25',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/contacts?space_id=2&q=maria&limit=25', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/contacts?space_id=2&q=maria&limit=25");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "48213",
      "name": "María López",
      "phone": "5215512345678",
      "email": "maria@ejemplo.com",
      "company": "Distribuidora Sol",
      "avatar_url": null,
      "notes": "Cliente mayorista, factura a crédito 30 días",
      "source": "api",
      "custom": { "external_id": "ERP-1042", "valor": 15000 },
      "space_id": "2",
      "created_at": "2026-07-19T18:04:11.221Z",
      "updated_at": "2026-07-20T09:12:03.887Z",
      "tags": [{ "id": "7", "name": "Mayorista", "color": "#0ea5e9" }]
    }
  ],
  "has_more": true,
  "next_cursor": "MjAyNi0wNy0xOVQxODowNDoxMS4yMjFafDQ4MjEz"
}
POST
/contacts
requiere: contacts:write

Crea un contacto. Si en ese mismo espacio ya existe uno con ese teléfono NO lo duplica: devuelve el que ya estaba con 200 y "deduplicated": true (al crear devuelve 201 y "deduplicated": false). Si el existente no tenía external_id y tú mandas uno, se lo rellena para que el ERP pueda reencontrarlo después; nunca pisa un external_id ya puesto ni ningún otro campo.

Parámetros

name (texto, máx 160) obligatorio si no mandas phone.
phone (texto, máx 20 dígitos) obligatorio si no mandas name. Se guarda como dígitos pelados; manda el número con lada país (52…). Es la clave de deduplicación.
email (texto, máx 160, opcional)
company (texto, máx 160, opcional)
notes (texto, máx 4000, opcional)
custom (objeto JSON, opcional) campos libres; se guardan en la columna jsonb.
external_id (texto, máx 190, opcional) id del cliente en tu sistema. Se guarda como custom.external_id y se puede buscar con ?external_id=. Si lo mandas de primer nivel gana sobre uno que venga dentro de custom.
space_id (opcional/obligatorio según la llave) si la llave está atada a un espacio se usa ése y se ignora éste. Si no lo está: con un solo espacio accesible se usa ése; con varios es OBLIGATORIO y falta = 400 (la respuesta incluye accessible_space_ids).

Ejemplo

curl -s -X POST 'https://crm.bodezy.com/api/v1/contacts' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI' 
  -H 'Content-Type: application/json' 
  -d '{
    "name": "María López",
    "phone": "+52 55 1234 5678",
    "email": "maria@ejemplo.com",
    "company": "Distribuidora Sol",
    "notes": "Cliente mayorista, factura a crédito 30 días",
    "external_id": "ERP-1042",
    "custom": { "valor": 15000 },
    "space_id": 2
  }'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/contacts');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'name' => 'María López',
    'phone' => '+52 55 1234 5678',
    'email' => 'maria@ejemplo.com',
    'company' => 'Distribuidora Sol',
    'notes' => 'Cliente mayorista, factura a crédito 30 días',
    'external_id' => 'ERP-1042',
    'custom' => '{'valor': 15000}',
    'space_id' => 2,
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.post(
    'https://crm.bodezy.com/api/v1/contacts',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'name': 'María López',
        'phone': '+52 55 1234 5678',
        'email': 'maria@ejemplo.com',
        'company': 'Distribuidora Sol',
        'notes': 'Cliente mayorista, factura a crédito 30 días',
        'external_id': 'ERP-1042',
        'custom': '{'valor': 15000}',
        'space_id': 2,
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/contacts', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "name": "María López",
    "phone": "+52 55 1234 5678",
    "email": "maria@ejemplo.com",
    "company": "Distribuidora Sol",
    "notes": "Cliente mayorista, factura a crédito 30 días",
    "external_id": "ERP-1042",
    "custom": {
      "valor": 15000
    },
    "space_id": 2
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""name"": ""María López"", ""phone"": ""+52 55 1234 5678"", ""email"": ""maria@ejemplo.com"", ""company"": ""Distribuidora Sol"", ""notes"": ""Cliente mayorista, factura a crédito 30 días"", ""external_id"": ""ERP-1042"", ""custom"": {""valor"": 15000}, ""space_id"": 2}",
    Encoding.UTF8, "application/json");

var r = await http.PostAsync("https://crm.bodezy.com/api/v1/contacts", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

// 201 Created — contacto nuevo
{
  "id": "48213",
  "name": "María López",
  "phone": "5215512345678",
  "email": "maria@ejemplo.com",
  "company": "Distribuidora Sol",
  "avatar_url": null,
  "notes": "Cliente mayorista, factura a crédito 30 días",
  "source": "api",
  "custom": { "valor": 15000, "external_id": "ERP-1042" },
  "space_id": "2",
  "created_at": "2026-07-20T09:12:03.887Z",
  "updated_at": "2026-07-20T09:12:03.887Z",
  "tags": [],
  "deduplicated": false
}

// 200 OK — el teléfono ya existía en ese espacio: te devuelve el de siempre
{
  "id": "31007",
  "name": "María L.",
  "phone": "5215512345678",
  "source": "whatsapp_qr",
  "custom": { "external_id": "ERP-1042" },
  "space_id": "2",
  "created_at": "2026-05-02T14:20:00.000Z",
  "updated_at": "2026-07-20T09:12:03.887Z",
  "tags": [{ "id": "7", "name": "Mayorista", "color": "#0ea5e9" }],
  "deduplicated": true
}

// 400 — varios espacios accesibles y no elegiste
{
  "error": {
    "code": "invalid_request",
    "message": "Falta space_id: esta llave alcanza varios espacios y hay que elegir uno.",
    "accessible_space_ids": [2, 5]
  }
}
GET
/contacts/{id}
solo lectura

Devuelve un contacto por su id, con sus etiquetas. Si el id no existe o pertenece a un espacio fuera del alcance de la llave responde 404 idéntico en ambos casos: no se distingue "no existe" de "no es tuyo" para no filtrar qué ids son reales.

Parámetros

id (en la ruta) el id del contacto tal como lo devuelven los demás endpoints (viene como texto porque es bigint y JavaScript pierde precisión al pasarlo a number).

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/contacts/48213' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/contacts/48213');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/contacts/48213',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/contacts/48213', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/contacts/48213");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "id": "48213",
  "name": "María López",
  "phone": "5215512345678",
  "email": "maria@ejemplo.com",
  "company": "Distribuidora Sol",
  "avatar_url": "https://crm.bodezy.com/uploads/avatars/48213.jpg",
  "notes": "Cliente mayorista, factura a crédito 30 días",
  "source": "api",
  "custom": { "valor": 15000, "external_id": "ERP-1042" },
  "space_id": "2",
  "created_at": "2026-07-19T18:04:11.221Z",
  "updated_at": "2026-07-20T09:12:03.887Z",
  "tags": [{ "id": "7", "name": "Mayorista", "color": "#0ea5e9" }]
}

// 404 — no existe o no lo alcanza tu llave (misma respuesta en los dos casos)
{ "error": { "code": "not_found", "message": "El contacto no existe o no es accesible." } }
PATCH
/contacts/{id}
requiere: contacts:write

Actualiza SÓLO los campos que vengan en el cuerpo; lo que no mandas queda intacto. `custom` se FUSIONA (jsonb ||): mandas las claves que cambian y las demás sobreviven, así dos sistemas pueden escribir en custom sin borrarse entre ellos. Devuelve el contacto ya actualizado, con la misma forma que el GET.

Parámetros

id (en la ruta)
name / email / company (texto, máx 160) mandar "" o null BORRA el campo; no mandar la clave lo deja como está.
phone (texto, máx 20 dígitos) se normaliza a dígitos.
notes (texto, máx 4000)
custom (objeto JSON) se fusiona clave por clave, NUNCA reemplaza el objeto completo.
external_id (texto, máx 190) atajo que escribe custom.external_id (aquí SÍ sobrescribe el anterior, a diferencia del POST).
space_id NO se puede cambiar por API: mover un contacto de espacio lo sacaría de la vista de su dueño.

Ejemplo

curl -s -X PATCH 'https://crm.bodezy.com/api/v1/contacts/48213' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI' 
  -H 'Content-Type: application/json' 
  -d '{
    "company": "Distribuidora Sol S.A. de C.V.",
    "external_id": "ERP-1042",
    "custom": { "limite_credito": 50000 }
  }'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/contacts/48213');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'company' => 'Distribuidora Sol S.A. de C.V.',
    'external_id' => 'ERP-1042',
    'custom' => '{'limite_credito': 50000}',
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.patch(
    'https://crm.bodezy.com/api/v1/contacts/48213',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'company': 'Distribuidora Sol S.A. de C.V.',
        'external_id': 'ERP-1042',
        'custom': '{'limite_credito': 50000}',
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/contacts/48213', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "company": "Distribuidora Sol S.A. de C.V.",
    "external_id": "ERP-1042",
    "custom": {
      "limite_credito": 50000
    }
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""company"": ""Distribuidora Sol S.A. de C.V."", ""external_id"": ""ERP-1042"", ""custom"": {""limite_credito"": 50000}}",
    Encoding.UTF8, "application/json");

var r = await http.PatchAsync("https://crm.bodezy.com/api/v1/contacts/48213", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "id": "48213",
  "name": "María López",
  "phone": "5215512345678",
  "email": "maria@ejemplo.com",
  "company": "Distribuidora Sol S.A. de C.V.",
  "avatar_url": null,
  "notes": "Cliente mayorista, factura a crédito 30 días",
  "source": "api",
  "custom": { "valor": 15000, "external_id": "ERP-1042", "limite_credito": 50000 },
  "space_id": "2",
  "created_at": "2026-07-19T18:04:11.221Z",
  "updated_at": "2026-07-20T10:41:55.010Z",
  "tags": [{ "id": "7", "name": "Mayorista", "color": "#0ea5e9" }]
}

// 400 — cuerpo sin ningún campo actualizable
{ "error": { "code": "invalid_request", "message": "No se envió ningún campo actualizable." } }

// 403 — la llave no tiene el permiso de escritura
{ "error": { "code": "insufficient_scope", "message": "Esta llave no tiene el permiso "contacts:write".", "required_scope": "contacts:write" } }

Conversaciones

GET
/conversations
solo lectura

Lista las conversaciones de los espacios que alcanza la llave, de la más reciente a la más vieja (por último mensaje; si el chat aún no tiene mensajes, por su fecha de creación). Paginada por cursor. Incluye las etiquetas del contacto.

Parámetros

space_id (opcional) limita a un espacio concreto. Debe estar dentro del alcance de la llave o responde 400.
status (opcional) open | pending | snoozed | closed. Se puede repetir: ?status=open&status=pending
stage_id (opcional) sólo las que están en esa etapa del embudo.
assigned_user_id (opcional) id del responsable, o "none" para las que no tienen dueño.
updated_since (opcional) fecha ISO-8601; devuelve lo que cambió después de ese instante (para sincronizaciones incrementales).
limit (opcional) 1-100, por omisión 50.
cursor (opcional) next_cursor de la página anterior.
contact_id (opcional) sólo las conversaciones de ESE contacto (su historial completo).
phone (opcional) lo mismo pero por número, útil si tu ERP no conoce el contact_id interno. Casa las variantes 52/521 de México.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/conversations?status=open&limit=2' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'

# Página siguiente (usa el next_cursor de la respuesta anterior):
curl -s 'https://crm.bodezy.com/api/v1/conversations?status=open&limit=2&cursor=MjAyNi0wNy0yMFQxODoxMTowMi40MTAyMzBafDkxNA' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'

# Sólo lo que cambió desde ayer, sin dueño asignado:
curl -s 'https://crm.bodezy.com/api/v1/conversations?updated_since=2026-07-19T00:00:00Z&assigned_user_id=none' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/conversations?status=open&limit=2');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/conversations?status=open&limit=2',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/conversations?status=open&limit=2', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/conversations?status=open&limit=2");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "914",
      "contact": { "id": "731", "name": "Laura Jiménez", "phone": "5215512345678" },
      "status": "open",
      "channel_type": "whatsapp_qr",
      "space_id": "2",
      "stage_id": "18",
      "pipeline_id": "4",
      "assigned_user_id": "37",
      "unread_count": 2,
      "last_message_at": "2026-07-20T18:11:02.410Z",
      "created_at": "2026-07-02T15:40:11.882Z",
      "updated_at": "2026-07-20T18:11:02.415Z",
      "tags": [{ "id": "9", "name": "Cotización", "color": "#0ea5e9" }]
    },
    {
      "id": "908",
      "contact": { "id": "725", "name": null, "phone": "5215598765432" },
      "status": "open",
      "channel_type": "webchat",
      "space_id": "2",
      "stage_id": null,
      "pipeline_id": null,
      "assigned_user_id": null,
      "unread_count": 0,
      "last_message_at": "2026-07-20T16:02:44.100Z",
      "created_at": "2026-07-20T16:02:41.006Z",
      "updated_at": "2026-07-20T16:02:44.104Z",
      "tags": []
    }
  ],
  "has_more": true,
  "next_cursor": "MjAyNi0wNy0yMFQxNjowMjo0NC4xMDAxMjBafDkwOA"
}
GET
/conversations/{id}
solo lectura

Devuelve una conversación por id, con los datos del contacto, el canal, el embudo y la etapa (con sus nombres) y las etiquetas. Si la conversación no existe o está fuera del alcance de la llave responde 404 not_found (nunca 403, para no revelar que existe).

Parámetros

id (en la ruta) id de la conversación.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/conversations/914' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/conversations/914');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/conversations/914',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/conversations/914', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/conversations/914");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "id": "914",
  "contact": {
    "id": "731",
    "name": "Laura Jiménez",
    "phone": "5215512345678",
    "email": "laura@ejemplo.com",
    "company": "Textiles del Norte"
  },
  "status": "open",
  "channel": { "id": "34", "type": "whatsapp_qr", "name": "Ventas MX" },
  "channel_type": "whatsapp_qr",
  "space_id": "2",
  "stage_id": "18",
  "stage_name": "Cotizado",
  "pipeline_id": "4",
  "pipeline_name": "Ventas",
  "assigned_user_id": "37",
  "unread_count": 2,
  "last_message_at": "2026-07-20T18:11:02.410Z",
  "created_at": "2026-07-02T15:40:11.882Z",
  "updated_at": "2026-07-20T18:11:02.415Z",
  "won_at": null,
  "lost_at": null,
  "tags": [{ "id": "9", "name": "Cotización", "color": "#0ea5e9" }]
}
PATCH
/conversations/{id}
requiere: pipeline:write

Actualiza la conversación: la mueve de etapa del embudo, le cambia el responsable y/o el estado. Manda sólo los campos que quieras cambiar. Fijar stage_id fija también el embudo (pipeline_id) al que pertenece esa etapa; si la etapa está marcada como ganada o perdida se sella won_at/lost_at y se disparan las automatizaciones del embudo, igual que si movieras la tarjeta a mano. Devuelve la conversación completa ya actualizada (mismo objeto que el GET de detalle).

Parámetros

id (en la ruta) id de la conversación.
stage_id (opcional, cuerpo) id de la etapa, o null para sacarla de la etapa. DEBE pertenecer a un embudo del MISMO espacio de la conversación; si no, 400 invalid_request.
assigned_user_id (opcional, cuerpo) id del usuario responsable, o null para dejarla sin dueño. Debe ser usuario de esta cuenta; si no, 400 invalid_request.
status (opcional, cuerpo) open | pending | snoozed | closed.

Ejemplo

# Mover de etapa y asignar responsable:
curl -s -X PATCH 'https://crm.bodezy.com/api/v1/conversations/914' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI' 
  -H 'Content-Type: application/json' 
  -d '{"stage_id": 19, "assigned_user_id": 37}'

# Cerrar la conversación y quitarle el responsable:
curl -s -X PATCH 'https://crm.bodezy.com/api/v1/conversations/914' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI' 
  -H 'Content-Type: application/json' 
  -d '{"status": "closed", "assigned_user_id": null}'

# Etapa de OTRO espacio → 400 invalid_request:
curl -s -X PATCH 'https://crm.bodezy.com/api/v1/conversations/914' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI' 
  -H 'Content-Type: application/json' 
  -d '{"stage_id": 402}'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/conversations/914');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'stage_id' => 19,
    'assigned_user_id' => 37,
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.patch(
    'https://crm.bodezy.com/api/v1/conversations/914',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'stage_id': 19,
        'assigned_user_id': 37,
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/conversations/914', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "stage_id": 19,
    "assigned_user_id": 37
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""stage_id"": 19, ""assigned_user_id"": 37}",
    Encoding.UTF8, "application/json");

var r = await http.PatchAsync("https://crm.bodezy.com/api/v1/conversations/914", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "id": "914",
  "contact": {
    "id": "731",
    "name": "Laura Jiménez",
    "phone": "5215512345678",
    "email": "laura@ejemplo.com",
    "company": "Textiles del Norte"
  },
  "status": "open",
  "channel": { "id": "34", "type": "whatsapp_qr", "name": "Ventas MX" },
  "channel_type": "whatsapp_qr",
  "space_id": "2",
  "stage_id": "19",
  "stage_name": "Ganado",
  "pipeline_id": "4",
  "pipeline_name": "Ventas",
  "assigned_user_id": "37",
  "unread_count": 2,
  "last_message_at": "2026-07-20T18:11:02.410Z",
  "created_at": "2026-07-02T15:40:11.882Z",
  "updated_at": "2026-07-20T19:04:55.221Z",
  "won_at": "2026-07-20T19:04:55.221Z",
  "lost_at": null,
  "tags": [{ "id": "9", "name": "Cotización", "color": "#0ea5e9" }]
}

// Etapa de otro espacio (400):
{
  "error": {
    "code": "invalid_request",
    "message": "La etapa no existe o pertenece a un embudo de otro espacio.",
    "param": "stage_id"
  }
}

Mensajes

GET
/conversations/{id}/messages
solo lectura

Historial de mensajes de una conversación, del más reciente al más antiguo, paginado por cursor (keyset sobre created_at + id). El cursor compara la TUPLA (created_at, id) y no sólo el id, porque el backfill de historial de WhatsApp inserta mensajes viejos con id alto y un cursor por id solo se saltaría páginas enteras. A diferencia del CRM web, leer por aquí NO marca la conversación como leída. Devuelve 404 si la conversación no existe o no pertenece a un espacio dentro del alcance de la llave (nunca 403: no se revela que exista en otra cuenta).

Parámetros

id (ruta) id de la conversación
limit (query, opcional) 1..100, por defecto 50
cursor (query, opcional) next_cursor de la página anterior

Ejemplo

curl -s -X GET 'https://crm.bodezy.com/api/v1/conversations/842/messages?limit=2' 
  -H 'Authorization: Bearer bzy_live_XcQ8vK2mNpR7tLwZaB3dYfHj'

# Página siguiente (más antiguos), usando el next_cursor de la respuesta:
curl -s -X GET 'https://crm.bodezy.com/api/v1/conversations/842/messages?limit=2&cursor=MjAyNi0wNy0yMFQxNzoxMjowNC4xMThafDkxNDQy' 
  -H 'Authorization: Bearer bzy_live_XcQ8vK2mNpR7tLwZaB3dYfHj'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/conversations/842/messages?limit=2');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/conversations/842/messages?limit=2',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/conversations/842/messages?limit=2', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/conversations/842/messages?limit=2");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "91443",
      "direction": "out",
      "type": "text",
      "body": "Con gusto, tu pedido sale mañana.",
      "media_url": null,
      "media_mime": null,
      "status": "sent",
      "created_at": "2026-07-20T17:14:52.903Z",
      "meta": { "via": "api", "api_token_id": "37" }
    },
    {
      "id": "91442",
      "direction": "in",
      "type": "image",
      "body": null,
      "media_url": "/api/media/9f2c1b7a-4e10-4f6d-9c33-8b1d2e5a7c04",
      "media_mime": "image/jpeg",
      "status": "delivered",
      "created_at": "2026-07-20T17:12:04.118Z",
      "meta": {}
    }
  ],
  "has_more": true,
  "next_cursor": "MjAyNi0wNy0yMFQxNzoxMjowNC4xMThafDkxNDQy"
}
POST
/conversations/{id}/messages
requiere: messages:send

Envía un mensaje de texto en la conversación y devuelve el mensaje creado (201). Usa internamente el mismo camino de salida que el CRM (deliverOutbound), así que respeta canales desconectados, la pausa de envío de coexistencia y la deduplicación del eco de Meta. El mensaje se guarda SIEMPRE, incluso si el proveedor falla: en ese caso queda con status 'failed', la respuesta es 502 con código 'send_failed' y el motivo real, e incluye message_id para que puedas mostrarlo o reintentar sin duplicar. Los mensajes enviados por API quedan marcados en meta con {via:'api', api_token_id}. Devuelve 404 si la conversación no está en el alcance de la llave.

Parámetros

id (ruta) id de la conversación
text (cuerpo, obligatorio) texto del mensaje; no puede ir vacío ni pasar de 4000 caracteres
template (objeto, opcional) ALTERNATIVA a `text` para escribir FUERA de la ventana de 24 h de WhatsApp. Forma: {"name":"pedido_enviado","language":"es_MX","params":["Ana","BZ-102"]}. Es excluyente con `text`, sólo funciona por WhatsApp Cloud API, y la plantilla debe estar aprobada por Meta (consulta GET /v1/templates). El número de `params` debe coincidir con las variables del cuerpo.
Idempotency-Key (CABECERA HTTP, opcional pero MUY recomendada al enviar) identificador único de la operación (por ejemplo un UUID). Si repites la petición con la misma cabecera, la API NO reenvía: devuelve la respuesta de la primera vez y añade la cabecera `Idempotent-Replayed: true`. Así, si tu sistema reintenta tras un corte de red, el cliente no recibe el mensaje dos veces ni se te cobra dos veces.

Ejemplo

curl -s -X POST 'https://crm.bodezy.com/api/v1/conversations/842/messages' 
  -H 'Authorization: Bearer bzy_live_XcQ8vK2mNpR7tLwZaB3dYfHj' 
  -H 'Content-Type: application/json' 
  -d '{"text":"Hola Ana, tu guía es 794512338891 y llega el martes."}'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/conversations/842/messages');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'text' => 'Hola Ana, tu guía es 794512338891 y llega el martes.',
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.post(
    'https://crm.bodezy.com/api/v1/conversations/842/messages',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'text': 'Hola Ana, tu guía es 794512338891 y llega el martes.',
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/conversations/842/messages', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "text": "Hola Ana, tu guía es 794512338891 y llega el martes."
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""text"": ""Hola Ana, tu guía es 794512338891 y llega el martes.""}",
    Encoding.UTF8, "application/json");

var r = await http.PostAsync("https://crm.bodezy.com/api/v1/conversations/842/messages", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

// 201 Created
{
  "data": {
    "id": "91444",
    "direction": "out",
    "type": "text",
    "body": "Hola Ana, tu guía es 794512338891 y llega el martes.",
    "media_url": null,
    "media_mime": null,
    "status": "sent",
    "created_at": "2026-07-20T17:20:11.442Z",
    "meta": { "via": "api", "api_token_id": "37" }
  }
}

// 400 — texto vacío o demasiado largo
{ "error": { "code": "invalid_request", "message": "El mensaje no puede pasar de 4000 caracteres.", "max_length": 4000, "length": 5231 } }

// 403 — la llave no tiene el permiso
{ "error": { "code": "insufficient_scope", "message": "Esta llave no tiene el permiso "messages:send".", "required_scope": "messages:send" } }

// 404 — no existe o está fuera del alcance de la llave
{ "error": { "code": "not_found", "message": "La conversación no existe o no es accesible." } }

// 502 — guardado pero NO entregado (el mensaje ya existe con status 'failed')
{ "error": { "code": "send_failed", "message": "Este canal está desconectado. Vuelve a conectarlo para enviar mensajes.", "message_id": "91445" } }
POST
/messages
requiere: messages:send

Envía un mensaje de texto por WhatsApp a un TELÉFONO, creando el contacto y la conversación si no existen. Pensado para integradores que solo conocen el número del cliente (e-commerce, ERP, formularios) y no los ids internos del CRM: en una sola llamada resuelve o crea todo. El campo `created` indica qué se creó, para que el integrador guarde los ids de su lado y no vuelva a crear nada. Solo funciona con canales WhatsApp (whatsapp_qr / whatsapp_cloud), porque son los únicos que se enrutan por número. ⚠️ VENTANA DE 24 H: WhatsApp solo acepta texto libre dentro de las 24 h siguientes al último mensaje del CLIENTE; fuera de esa ventana Meta rechaza el envío y solo pasan plantillas aprobadas. A diferencia del 'iniciar chat' de la interfaz, esta API NO hace fallback automático a la plantilla hello_world: informa el fallo con 502 send_failed y el integrador decide. El mensaje queda igualmente guardado en el CRM con estado 'failed', y el error 502 incluye los recursos creados para que el reintento no duplique contacto ni conversación. Respuestas: 201 enviado · 400 invalid_request · 401 token inválido/revocado/caducado · 403 insufficient_scope o plan_upgrade_required · 404 not_found (espacio o canal fuera de alcance) · 409 no_channel / channel_disconnected · 429 rate_limited (120 req/min por llave) · 502 send_failed.

Parámetros

phone (string, OBLIGATORIO) teléfono del destinatario con lada de país. Se normaliza a solo dígitos; se aceptan +, espacios y guiones. Entre 8 y 20 dígitos. Los móviles de México en formato 521XXXXXXXXXX se canonizan a 52XXXXXXXXXX para no duplicar el contacto.
text (string, OBLIGATORIO) contenido del mensaje. Máximo 4000 caracteres.
space_id (número o string, opcional) espacio desde el que se envía. Obligatorio si la llave puede ver varios espacios y no está limitada a uno; si se omite en ese caso, responde 400 con la lista en error.space_ids. Si la llave está limitada a un espacio, se usa ese. Un space_id fuera del alcance devuelve 404 (nunca 403: no se revela que existe).
name (string, opcional) nombre para el contacto. Solo se usa al CREARLO, o para rellenar el nombre si el contacto ya existe y lo tiene vacío; nunca pisa un nombre que ya escribió un agente. Máximo 160 caracteres.
channel_id (número o string, opcional) canal WhatsApp concreto desde el que enviar. Se valida que sea del mismo espacio, de tipo whatsapp_qr o whatsapp_cloud y que esté conectado. Si se omite, se prefiere el canal de una conversación viva del contacto (para no partir su historial) y si no hay, el primer WhatsApp conectado del espacio.
template (objeto, opcional) ALTERNATIVA a `text` para escribir FUERA de la ventana de 24 h de WhatsApp. Forma: {"name":"pedido_enviado","language":"es_MX","params":["Ana","BZ-102"]}. Es excluyente con `text`, sólo funciona por WhatsApp Cloud API, y la plantilla debe estar aprobada por Meta (consulta GET /v1/templates). El número de `params` debe coincidir con las variables del cuerpo.
Idempotency-Key (CABECERA HTTP, opcional pero MUY recomendada al enviar) identificador único de la operación (por ejemplo un UUID). Si repites la petición con la misma cabecera, la API NO reenvía: devuelve la respuesta de la primera vez y añade la cabecera `Idempotent-Replayed: true`. Así, si tu sistema reintenta tras un corte de red, el cliente no recibe el mensaje dos veces ni se te cobra dos veces.

Ejemplo

curl -sS -X POST https://crm.bodezy.com/api/v1/messages 
  -H "Authorization: Bearer bzy_live_TU_LLAVE_AQUI" 
  -H "Content-Type: application/json" 
  -d '{
    "phone": "+52 55 1234 5678",
    "text": "Hola Ana, tu pedido #4821 ya salió de almacén. Llega mañana entre 9 y 2.",
    "name": "Ana Torres",
    "space_id": 2
  }'

# Forzando un WhatsApp concreto (si tienes varios conectados en el espacio):
curl -sS -X POST https://crm.bodezy.com/api/v1/messages 
  -H "Authorization: Bearer bzy_live_TU_LLAVE_AQUI" 
  -H "Content-Type: application/json" 
  -d '{"phone":"5215512345678","text":"Tu cotización está lista.","channel_id":34}'

# Mínimo indispensable (llave limitada a un solo espacio):
curl -sS -X POST https://crm.bodezy.com/api/v1/messages 
  -H "Authorization: Bearer bzy_live_TU_LLAVE_AQUI" 
  -H "Content-Type: application/json" 
  -d '{"phone":"5551234567","text":"Gracias por tu compra."}'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/messages');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'phone' => '+52 55 1234 5678',
    'text' => 'Hola Ana, tu pedido #4821 ya salió de almacén. Llega mañana entre 9 y 2.',
    'name' => 'Ana Torres',
    'space_id' => 2,
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.post(
    'https://crm.bodezy.com/api/v1/messages',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'phone': '+52 55 1234 5678',
        'text': 'Hola Ana, tu pedido #4821 ya salió de almacén. Llega mañana entre 9 y 2.',
        'name': 'Ana Torres',
        'space_id': 2,
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/messages', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "phone": "+52 55 1234 5678",
    "text": "Hola Ana, tu pedido #4821 ya salió de almacén. Llega mañana entre 9 y 2.",
    "name": "Ana Torres",
    "space_id": 2
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""phone"": ""+52 55 1234 5678"", ""text"": ""Hola Ana, tu pedido #4821 ya salió de almacén. Llega mañana entre 9 y 2."", ""name"": ""Ana Torres"", ""space_id"": 2}",
    Encoding.UTF8, "application/json");

var r = await http.PostAsync("https://crm.bodezy.com/api/v1/messages", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

// 201 Created — enviado
{
  "contact": {
    "id": "18432",
    "name": "Ana Torres",
    "phone": "525512345678",
    "space_id": "2"
  },
  "conversation": {
    "id": "9071",
    "status": "open",
    "channel_id": "34",
    "channel_type": "whatsapp_cloud",
    "space_id": "2"
  },
  "message": {
    "id": "415902",
    "conversation_id": "9071",
    "direction": "out",
    "type": "text",
    "body": "Hola Ana, tu pedido #4821 ya salió de almacén. Llega mañana entre 9 y 2.",
    "status": "sent",
    "created_at": "2026-07-20T18:42:11.503Z"
  },
  "created": { "contact": true, "conversation": true }
}

// 502 — guardado en el CRM pero NO entregado (causa nº1: ventana de 24 h).
// Trae los recursos dentro para que el reintento no duplique nada.
{
  "error": {
    "code": "send_failed",
    "message": "El mensaje se guardó en el CRM pero el canal no pudo entregarlo. Causa más común: fuera de la ventana de 24 h de WhatsApp el texto libre se rechaza y hace falta una plantilla aprobada.",
    "contact": { "id": "18432", "name": "Ana Torres", "phone": "525512345678", "space_id": "2" },
    "conversation": { "id": "9071", "status": "open", "channel_id": "34", "channel_type": "whatsapp_cloud", "space_id": "2" },
    "message": { "id": "415903", "conversation_id": "9071", "direction": "out", "type": "text", "body": "Hola Ana...", "status": "failed", "created_at": "2026-07-20T18:44:02.117Z" },
    "created": { "contact": false, "conversation": false }
  }
}

// 409 — el espacio no tiene ningún WhatsApp conectado
{ "error": { "code": "no_channel", "message": "Este espacio no tiene ningún WhatsApp conectado para enviar. Conéctalo en Conexiones.", "space_id": "2" } }

// 409 — el channel_id que mandaste está caído
{ "error": { "code": "channel_disconnected", "message": "El canal 34 no está conectado. Vuelve a conectarlo en Conexiones." } }

// 400 — la llave ve varios espacios y no dijiste cuál
{ "error": { "code": "invalid_request", "message": "Esta llave puede ver varios espacios: indica `space_id` para saber desde cuál enviar.", "space_ids": ["1", "2"] } }

// 403 — la llave no tiene el permiso
{ "error": { "code": "insufficient_scope", "message": "Esta llave no tiene el permiso "messages:send".", "required_scope": "messages:send" } }

Cotizaciones

POST
/quotes
requiere: messages:send

Crea una COTIZACIÓN desde tu ERP y la muestra como tarjeta en el chat del cliente, con su link público para FIRMAR y PAGAR. Identifica al cliente por `contact_id`, `phone` o `conversation_id` (uno de los tres); si mandas un teléfono nuevo, se crea el contacto. Las partidas son MANUALES: el precio lo pones tú (tu ERP es dueño del catálogo). El `total_cents` que devuelve es EXACTAMENTE lo que el cliente firmará y pagará. Usa la cabecera `Idempotency-Key` para que un reintento no cree dos cotizaciones.

Parámetros

contact_id (uno de los 3) id del contacto en el CRM.
phone (uno de los 3) teléfono con lada de país; si no existe el contacto, se crea. Casa 52/521.
conversation_id (uno de los 3) id de una conversación existente; fija el contacto y el espacio.
space_id (opcional) obligatorio si la llave ve varios espacios y usas phone/contact_id.
items (obligatorio) arreglo de partidas {"name":"…","qty":3,"unit_price_cents":25000,"sku":"OPC"}. Máx 50. Los precios van en CENTAVOS.
tax_rate (opcional) % de IVA (0 = sin IVA).
discount_cents (opcional) descuento en centavos, se resta antes del IVA.
valid_until (opcional) 'AAAA-MM-DD' hasta cuándo es válida.
notes (opcional) notas para el cliente.
pay_method (opcional) mercadopago | stripe | efectivo | auto (default auto).
stripe_methods (opcional) métodos de Stripe a aceptar: ["card","spei","oxxo"].
surcharge (opcional) true = el cliente paga la comisión (el total se ajusta).
send (opcional, default true) si false, crea la cotización SIN mandar la tarjeta al chat.
Idempotency-Key (CABECERA, recomendada) identificador único; un reintento con la misma NO crea otra cotización.

Ejemplo

curl -s -X POST 'https://crm.bodezy.com/api/v1/quotes' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI' 
  -H 'Content-Type: application/json' 
  -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' 
  -d '{"phone":"5215500000000","space_id":1,"tax_rate":16,"items":[{"name":"Playera sublimada","qty":3,"unit_price_cents":25000}]}'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/quotes');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'phone' => '5215500000000',
    'space_id' => 1,
    'tax_rate' => 16,
    'items' => '[{'name': 'Playera sublimada', 'qty': 3, 'unit_price_cents': 25000}]',
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.post(
    'https://crm.bodezy.com/api/v1/quotes',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'phone': '5215500000000',
        'space_id': 1,
        'tax_rate': 16,
        'items': '[{'name': 'Playera sublimada', 'qty': 3, 'unit_price_cents': 25000}]',
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/quotes', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "phone": "5215500000000",
    "space_id": 1,
    "tax_rate": 16,
    "items": [
      {
        "name": "Playera sublimada",
        "qty": 3,
        "unit_price_cents": 25000
      }
    ]
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""phone"": ""5215500000000"", ""space_id"": 1, ""tax_rate"": 16, ""items"": [{""name"": ""Playera sublimada"", ""qty"": 3, ""unit_price_cents"": 25000}]}",
    Encoding.UTF8, "application/json");

var r = await http.PostAsync("https://crm.bodezy.com/api/v1/quotes", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "quote": {
    "id": "4",
    "uuid": "9b3a999d-1428-44f0-891b-0a9984da5e50",
    "folio": "COT-000004",
    "status": "sent",
    "public_url": "https://bodezy.com/cotizacion/mi-negocio/9b3a999d-1428-44f0-891b-0a9984da5e50",
    "currency": "MXN",
    "subtotal_cents": 75000,
    "discount_cents": 0,
    "tax_cents": 12000,
    "total_cents": 87000,
    "commission_cents": 0,
    "surcharge": false,
    "pay_method": "auto",
    "valid_until": null,
    "contact": { "id": "1080", "name": "Ana López", "phone": "5215500000000" },
    "conversation_id": "41803",
    "delivered": true
  },
  "created": { "contact": true }
}
GET
/quotes
solo lectura

Lista tus cotizaciones, de la más reciente a la más vieja, con paginación por cursor. `status` es el estado de la cotización (borrador, enviada, vista, aceptada…); `charge_status`/`is_paid` dicen si el cobro ligado ya se pagó. Filtra por cliente para sincronizar el estado en tu ERP.

Parámetros

status (opcional, repetible) draft | sent | viewed | accepted | rejected | expired | canceled.
contact_id (opcional) sólo las de ese contacto.
phone (opcional) lo mismo por teléfono (casa 52/521).
conversation_id (opcional) sólo las de esa conversación.
space_id (opcional) limita a un espacio.
limit (opcional) 1–100, default 50.
cursor (opcional) el next_cursor de la página anterior.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/quotes?status=accepted' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/quotes?status=accepted');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/quotes?status=accepted',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/quotes?status=accepted', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/quotes?status=accepted");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "4",
      "uuid": "9b3a999d-1428-44f0-891b-0a9984da5e50",
      "folio": "COT-000004",
      "status": "accepted",
      "charge_status": "paid",
      "is_paid": true,
      "total_cents": 87000,
      "currency": "MXN",
      "pay_method": "stripe",
      "valid_until": null,
      "created_at": "2026-07-20T22:00:00.000Z",
      "accepted_at": "2026-07-20T22:10:00.000Z",
      "contact": { "id": "1080", "name": "Ana López", "phone": "5215500000000" },
      "conversation_id": "41803",
      "public_url": "https://bodezy.com/cotizacion/mi-negocio/9b3a999d-1428-44f0-891b-0a9984da5e50"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
GET
/quotes/{id}
solo lectura

El detalle completo de UNA cotización: totales, partidas y el estado de firma y de pago. Es lo que consultas para saber si el cliente ya FIRMÓ (status "accepted", con signed_name) o ya PAGÓ (is_paid true, con paid_at), y así registrar la venta en tu ERP.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/quotes/4' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/quotes/4');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/quotes/4',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/quotes/4', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/quotes/4");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "id": "4",
  "uuid": "9b3a999d-1428-44f0-891b-0a9984da5e50",
  "folio": "COT-000004",
  "status": "accepted",
  "charge_status": "paid",
  "is_paid": true,
  "paid_at": "2026-07-20T22:12:00.000Z",
  "pay_method": "stripe",
  "currency": "MXN",
  "surcharge": false,
  "subtotal_cents": 75000,
  "discount_cents": 0,
  "tax_rate": 16,
  "tax_cents": 12000,
  "total_cents": 87000,
  "commission_cents": 0,
  "notes": null,
  "valid_until": null,
  "created_at": "2026-07-20T22:00:00.000Z",
  "viewed_at": "2026-07-20T22:05:00.000Z",
  "accepted_at": "2026-07-20T22:10:00.000Z",
  "signed_name": "Ana López",
  "public_url": "https://bodezy.com/cotizacion/mi-negocio/9b3a999d-1428-44f0-891b-0a9984da5e50",
  "contact": { "id": "1080", "name": "Ana López", "phone": "5215500000000" },
  "conversation_id": "41803",
  "items": [
    {
      "name": "Playera sublimada",
      "sku": null,
      "qty": 3,
      "unit_price_cents": 25000,
      "line_total_cents": 75000,
      "source": "manual",
      "variante_id": null
    }
  ]
}

Webhooks (avisos en tiempo real)

POST
/webhooks
solo lectura

Registra una URL (https) donde Bodezy te AVISA en tiempo real cuando una cotización cambia, en vez de que preguntes. Eventos: quote.created, quote.sent, quote.viewed, quote.accepted (el cliente firmó) y quote.paid (el cliente pagó). CADA entrega llega firmada: cabecera `X-Bodezy-Signature: t=<ts>,v1=<hmac>`. Verifícala calculando HMAC-SHA256(tu_secreto, `<ts>.<cuerpo_crudo>`) y comparando con v1 — así confirmas que el evento vino de Bodezy y no fue alterado. El `secret` para verificar se devuelve UNA sola vez, aquí. Si tu servidor no responde 2xx, se reintenta con espera creciente (hasta ~22 h); usa la cabecera `X-Bodezy-Delivery` para deduplicar. El cuerpo del evento es: { "type":"quote.paid", "created_at":"…", "space_id":"1", "data":{ "quote":{ "id":"4", "folio":"COT-000004", "status":"accepted", "is_paid":true, "total_cents":87000, "public_url":"…", "contact":{…} } } }.

Parámetros

url (obligatorio) https a donde mandamos los eventos. No puede ser una dirección interna/privada.
events (opcional) arreglo con los eventos a recibir, p.ej. ["quote.accepted","quote.paid"]. Por defecto TODOS ("*").
space_id (opcional) recibir sólo eventos de ese espacio. Por defecto todos los de la cuenta.
description (opcional) una nota para identificar el webhook.

Ejemplo

curl -s -X POST 'https://crm.bodezy.com/api/v1/webhooks' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI' 
  -H 'Content-Type: application/json' 
  -d '{"url":"https://tu-erp.com/webhooks/bodezy","events":["quote.accepted","quote.paid"]}'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/webhooks');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'url' => 'https://tu-erp.com/webhooks/bodezy',
    'events' => '['quote.accepted', 'quote.paid']',
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.post(
    'https://crm.bodezy.com/api/v1/webhooks',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'url': 'https://tu-erp.com/webhooks/bodezy',
        'events': '['quote.accepted', 'quote.paid']',
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "url": "https://tu-erp.com/webhooks/bodezy",
    "events": [
      "quote.accepted",
      "quote.paid"
    ]
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""url"": ""https://tu-erp.com/webhooks/bodezy"", ""events"": [""quote.accepted"", ""quote.paid""]}",
    Encoding.UTF8, "application/json");

var r = await http.PostAsync("https://crm.bodezy.com/api/v1/webhooks", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "id": "3",
  "url": "https://tu-erp.com/webhooks/bodezy",
  "events": ["quote.accepted", "quote.paid"],
  "space_id": null,
  "description": null,
  "active": true,
  "created_at": "2026-07-20T23:00:00.000Z",
  "secret": "whsec_guarda_esto_no_se_vuelve_a_mostrar"
}
GET
/webhooks
solo lectura

Lista tus webhooks registrados, con su salud (último éxito, último error, fallos consecutivos). NUNCA devuelve el `secret`. Para editar uno usa PATCH /v1/webhooks/{id} (active, events, url, description); para borrarlo, DELETE /v1/webhooks/{id}.

Parámetros

space_id (opcional) limita a un espacio.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/webhooks' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/webhooks');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/webhooks',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/webhooks', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/webhooks");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "3",
      "url": "https://tu-erp.com/webhooks/bodezy",
      "events": ["quote.accepted", "quote.paid"],
      "space_id": null,
      "description": null,
      "active": true,
      "created_at": "2026-07-20T23:00:00.000Z",
      "health": {
        "last_success_at": "2026-07-20T23:05:00.000Z",
        "last_error": null,
        "last_error_at": null,
        "consecutive_failures": 0
      }
    }
  ]
}
DELETE
/webhooks/{id}
solo lectura

Borra un webhook (y sus entregas en cola). Deja de recibir eventos en esa URL.

Ejemplo

curl -s -X DELETE 'https://crm.bodezy.com/api/v1/webhooks/3' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/webhooks/3');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.delete(
    'https://crm.bodezy.com/api/v1/webhooks/3',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/webhooks/3', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.DeleteAsync("https://crm.bodezy.com/api/v1/webhooks/3");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "deleted": true,
  "id": "3"
}

Plantillas de WhatsApp

GET
/templates
solo lectura

Lista las plantillas de WhatsApp aprobadas por Meta que puedes enviar FUERA de la ventana de 24 horas. La clave real de una plantilla es name + language (una misma plantilla puede existir en varios idiomas). `variables` dice cuántos {{1}}, {{2}}… lleva el cuerpo — debes mandar exactamente esa cantidad en `template.params` al enviar. Sólo aplica a canales de WhatsApp Cloud API.

Parámetros

space_id (opcional) de qué espacio listar. Obligatorio si la llave ve varios espacios.
channel_id (opcional) un canal Cloud concreto.
include_unapproved (opcional) =1 para incluir también las que están en revisión o rechazadas.
refresh (opcional) =1 fuerza releer de Meta saltando la caché de 5 minutos (uso moderado).

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/templates?space_id=1' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/templates?space_id=1');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/templates?space_id=1',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/templates?space_id=1', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/templates?space_id=1");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "channel_id": "54",
      "space_id": "1",
      "name": "pedido_enviado",
      "language": "es_MX",
      "status": "APPROVED",
      "category": "UTILITY",
      "body": "Hola {{1}}, tu pedido {{2}} ya va en camino.",
      "variables": 2,
      "sample": false
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Usuarios y canales

GET
/users
solo lectura

Los usuarios (agentes) de tu cuenta. Sirve para traducir el `assigned_user_id` de una conversación a una persona, y para poner un menú de vendedores en tu integración. Si la llave está limitada a un espacio, sólo devuelve a quienes pueden acceder a ese espacio.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/users' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/users');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/users',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/users', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/users");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "4",
      "name": "Alberto Salinas",
      "email": "ventas@ejemplo.com",
      "role": "owner",
      "custom_role": null,
      "status": "active"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
GET
/channels
solo lectura

Los canales (números de WhatsApp, páginas de Facebook/Instagram, correo…) de tu cuenta. Úsalo para saber qué `channel_id` usar al enviar y cuál está conectado. Nunca devuelve tokens ni secretos: sólo datos públicos del canal.

Parámetros

space_id (opcional) limita a un espacio concreto dentro del alcance de la llave.

Ejemplo

curl -s 'https://crm.bodezy.com/api/v1/channels' 
  -H 'Authorization: Bearer bzy_live_TU_LLAVE_AQUI'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/channels');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/channels',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/channels', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/channels");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "54",
      "space_id": "1",
      "type": "whatsapp_cloud",
      "name": "Ventas MX",
      "identifier": "5215500000000",
      "status": "connected",
      "created_at": "2026-05-01T18:00:00.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Etiquetas y embudos

GET
/tags
solo lectura

Lista el catálogo de etiquetas que la llave puede ver: las de sus espacios MÁS las globales (space_id null = compartidas por toda la cuenta). Excluye los grupos, que viven en la misma tabla pero son otro recurso. No pagina: es un catálogo acotado y la tabla no tiene created_at para armar el cursor; siempre devuelve has_more:false y next_cursor:null.

Parámetros

ninguno (no acepta query params; el alcance sale de la llave)

Ejemplo

curl -s https://crm.bodezy.com/api/v1/tags 
  -H "Authorization: Bearer bzy_live_TU_LLAVE"
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/tags');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/tags',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/tags', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/tags");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    { "id": "12", "name": "VIP", "color": "#f59e0b", "space_id": null, "global": true },
    { "id": "34", "name": "Cotización enviada", "color": "#0ea5e9", "space_id": "2", "global": false },
    { "id": "35", "name": "Mayoreo", "color": "#10b981", "space_id": "2", "global": false }
  ],
  "has_more": false,
  "next_cursor": null
}
POST
/contacts/{id}/tags
requiere: contacts:write

Pega una etiqueta a un contacto. Es idempotente: repetir la llamada no falla, solo devuelve added:false. Valida que el contacto esté en el alcance de la llave y que la etiqueta sea del alcance o global; si alguno no lo está responde 404 (nunca 403, para no confirmar que el id existe en otra cuenta). No acepta grupos.

Parámetros

id (en la ruta) id numérico del contacto
tag_id (cuerpo, obligatorio) id numérico de la etiqueta; se acepta tagId como alias

Ejemplo

curl -s -X POST https://crm.bodezy.com/api/v1/contacts/8821/tags 
  -H "Authorization: Bearer bzy_live_TU_LLAVE" 
  -H "Content-Type: application/json" 
  -d '{"tag_id": 34}'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/contacts/8821/tags');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'tag_id' => 34,
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.post(
    'https://crm.bodezy.com/api/v1/contacts/8821/tags',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'tag_id': 34,
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/contacts/8821/tags', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "tag_id": 34
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""tag_id"": 34}",
    Encoding.UTF8, "application/json");

var r = await http.PostAsync("https://crm.bodezy.com/api/v1/contacts/8821/tags", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "ok": true,
  "contact_id": "8821",
  "tag_id": "34",
  "added": true
}

// Si el contacto ya la tenía (mismo 200, sin duplicar):
// { "ok": true, "contact_id": "8821", "tag_id": "34", "added": false }

// Si el contacto o la etiqueta no son del alcance (404):
// { "error": { "code": "not_found", "message": "El contacto no existe o no es accesible." } }

// Si a la llave le falta el permiso (403):
// { "error": { "code": "insufficient_scope", "message": "Esta llave no tiene el permiso "contacts:write".", "required_scope": "contacts:write" } }
DELETE
/contacts/{id}/tags
requiere: contacts:write

Quita una etiqueta de un contacto. Idempotente: si el contacto no la tenía responde 200 con removed:false. Hace las mismas validaciones de alcance que el POST. El tag_id va en el CUERPO (no en la ruta), así que el cliente HTTP debe permitir cuerpo en DELETE.

Parámetros

id (en la ruta) id numérico del contacto
tag_id (cuerpo, obligatorio) id numérico de la etiqueta; se acepta tagId como alias

Ejemplo

curl -s -X DELETE https://crm.bodezy.com/api/v1/contacts/8821/tags 
  -H "Authorization: Bearer bzy_live_TU_LLAVE" 
  -H "Content-Type: application/json" 
  -d '{"tag_id": 34}'
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/contacts/8821/tags');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
    'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'tag_id' => 34,
]));

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.delete(
    'https://crm.bodezy.com/api/v1/contacts/8821/tags',
    headers={'Authorization': f'Bearer {LLAVE}'},
    json={
        'tag_id': 34,
    },
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/contacts/8821/tags', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "tag_id": 34
  }),
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var cuerpo = new StringContent(
    @"{""tag_id"": 34}",
    Encoding.UTF8, "application/json");

var r = await http.DeleteAsync("https://crm.bodezy.com/api/v1/contacts/8821/tags", cuerpo);

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "ok": true,
  "contact_id": "8821",
  "tag_id": "34",
  "removed": true
}

// Si no la tenía puesta:
// { "ok": true, "contact_id": "8821", "tag_id": "34", "removed": false }

// Si falta tag_id en el cuerpo (400):
// { "error": { "code": "invalid_request", "message": "Falta "tag_id" (el id numérico de la etiqueta) en el cuerpo." } }
GET
/pipelines
solo lectura

Devuelve los embudos de los espacios de la llave con sus etapas ANIDADAS (json_agg), ordenados con el predeterminado primero y las etapas por posición. Se anidan porque una etapa no significa nada fuera de su embudo: para mover una conversación se necesita el par embudo+etapa. No pagina (catálogo acotado): has_more siempre false.

Parámetros

ninguno (no acepta query params; el alcance sale de la llave)

Ejemplo

curl -s https://crm.bodezy.com/api/v1/pipelines 
  -H "Authorization: Bearer bzy_live_TU_LLAVE"
<?php
$ch = curl_init('https://crm.bodezy.com/api/v1/pipelines');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $LLAVE,
]);

$respuesta = json_decode(curl_exec($ch), true);
$codigo = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($codigo >= 400) {
    // La API siempre responde { error: { code, message } }
    throw new Exception($respuesta['error']['message'] ?? 'Error');
}
print_r($respuesta);
import requests

LLAVE = 'bzy_live_...'  # guárdala en una variable de entorno

r = requests.get(
    'https://crm.bodezy.com/api/v1/pipelines',
    headers={'Authorization': f'Bearer {LLAVE}'},
    timeout=20,
)

if not r.ok:
    # La API siempre responde { error: { code, message } }
    raise RuntimeError(r.json()['error']['message'])

print(r.json())
const LLAVE = process.env.BODEZY_API_KEY;

const r = await fetch('https://crm.bodezy.com/api/v1/pipelines', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${LLAVE}`,
  },
});

const datos = await r.json();
if (!r.ok) {
  // La API siempre responde { error: { code, message } }
  throw new Error(datos.error.message);
}
console.log(datos);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", llave);

var r = await http.GetAsync("https://crm.bodezy.com/api/v1/pipelines");

var texto = await r.Content.ReadAsStringAsync();
if (!r.IsSuccessStatusCode)
    // La API siempre responde { error: { code, message } }
    throw new Exception(texto);

Console.WriteLine(texto);

Respuesta

{
  "data": [
    {
      "id": "3",
      "space_id": "2",
      "name": "Ventas",
      "is_default": true,
      "stages": [
        { "id": "11", "name": "Nuevo", "color": "#3b82f6", "position": 0, "is_won": false, "is_lost": false, "probability": 10 },
        { "id": "12", "name": "Contactado", "color": "#8b5cf6", "position": 1, "is_won": false, "is_lost": false, "probability": 30 },
        { "id": "13", "name": "En negociación", "color": "#f59e0b", "position": 2, "is_won": false, "is_lost": false, "probability": 60 },
        { "id": "14", "name": "Ganado", "color": "#10b981", "position": 3, "is_won": true, "is_lost": false, "probability": 100 },
        { "id": "15", "name": "Perdido", "color": "#ef4444", "position": 4, "is_won": false, "is_lost": true, "probability": 0 }
      ]
    },
    {
      "id": "7",
      "space_id": "2",
      "name": "Postventa",
      "is_default": false,
      "stages": []
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Códigos de error

HTTP code Qué significa y cómo se arregla
401 missing_token No mandaste la cabecera Authorization.
401 invalid_token La llave no existe. Revisa que la copiaste completa.
401 token_revoked La revocaron desde el CRM. Crea una nueva.
401 token_expired Se le pasó la fecha de caducidad.
403 account_inactive La cuenta no está activa.
403 plan_upgrade_required Ningún espacio alcanzable tiene la API en su plan.
403 insufficient_scope Falta un permiso. Crea una llave que lo incluya.
400 invalid_request Falta un dato o llegó mal. El mensaje dice cuál.
404 not_found No existe o está fuera del alcance de tu llave.
409 no_channel No hay ningún canal conectado para enviar.
429 rate_limited Vas muy rápido. Espera los segundos de retry_after.
502 send_failed El mensaje no salió (canal caído o rechazo de WhatsApp).
Por qué 404 y no 403 al pedir algo ajeno: responder “existe pero no puedes verlo” ya revelaría que existe. Un recurso fuera de tu alcance sencillamente no existe para tu llave.

Límites de uso

Cada llave admite 120 peticiones por minuto. Al pasarte recibes 429 con retry_after en segundos.

X-RateLimit-Limit: 120
X-RateLimit-Window: 60
  • Para sincronizar mucha información, usa updated_since y trae sólo lo que cambió.
  • Ante un 429 o un 5xx, reintenta con espera creciente (1s, 2s, 4s…).

Recetas

Enlazar tu ERP con el CRM

El problema clásico: tu ERP y tu CRM no saben que hablan del mismo cliente. Se resuelve con external_id, donde guardas el identificador que usa tu ERP.

# 1) Das de alta al cliente y lo enlazas con tu ERP
curl -s -X POST 'https://crm.bodezy.com/api/v1/contacts' 
  -H 'Authorization: Bearer TU_LLAVE' -H 'Content-Type: application/json' 
  -d '{"name":"Ana Torres","phone":"5215512345678","external_id":"ERP-00194"}'

# 2) Después lo reencuentras por el id de TU sistema
curl -s 'https://crm.bodezy.com/api/v1/contacts?external_id=ERP-00194' 
  -H 'Authorization: Bearer TU_LLAVE'
No duplica. Si el teléfono ya existe en ese espacio, la API te devuelve el contacto que ya estaba con "deduplicated": true en vez de partir en dos el historial. Así puedes reintentar sin miedo.

Avisar desde tu sistema por WhatsApp

Un solo llamado: si el contacto no existe lo crea, si no hay conversación la abre, y manda el mensaje.

curl -s -X POST 'https://crm.bodezy.com/api/v1/messages' 
  -H 'Authorization: Bearer TU_LLAVE' -H 'Content-Type: application/json' 
  -d '{"phone":"5215512345678","name":"Ana Torres",
       "text":"Tu pedido #1042 ya salió. Guía: 794512338891."}'
La ventana de 24 horas de WhatsApp. WhatsApp sólo deja escribir libremente durante las 24 h siguientes al último mensaje del cliente. Fuera de esa ventana el envío puede rechazarse y recibirás send_failed: para esos casos hay que usar una plantilla aprobada.

Sincronizar sólo lo que cambió

curl -s 'https://crm.bodezy.com/api/v1/contacts?updated_since=2026-07-20T00:00:00Z&limit=100' 
  -H 'Authorization: Bearer TU_LLAVE'

Preguntas frecuentes

¿Puede una llave ver datos de otra cuenta?

No. Cada llave queda atada a una cuenta y, dentro de ella, a los espacios permitidos. Toda consulta se filtra por ese alcance; lo que queda fuera responde 404.

Perdí mi llave, ¿la puedo recuperar?

No, y es a propósito: sólo guardamos una huella cifrada, nunca la llave. Revoca la anterior y crea una nueva.

¿Enviar por API consume mis mensajes?

Sí. Sale por tu número real de WhatsApp igual que si lo escribiera un agente, y aparece en la conversación del CRM.

¿Hay webhooks para que me avisen a mí?

Todavía no. Hoy la integración es en un sentido (tú consultas y escribes). Los avisos automáticos hacia tu sistema son el siguiente paso.

¿Puedo probar sin arriesgar?

Sí: crea una llave sin permisos de escritura y limitada a un espacio. Podrás leer todo sin poder modificar ni enviar nada.

¿Listo para conectar tu sistema?

Crea tu llave desde el CRM en menos de un minuto.

Crear mi llave de API