openapi: 3.0.3 info: title: Bunsale — API pública de prospectos version: 1.0.0 description: | Punto único de captación de prospectos para las landing pages de Bun Digital. Cada prospecto enviado a esta API crea automáticamente en Bunsale: * un **contacto** (o reutiliza el existente si el email o teléfono ya está registrado), * una **empresa** (si se envía el campo `empresa`), * un **deal** en la etapa **"Lead entrante"** del pipeline por defecto del workspace, * una **tarea de seguimiento** para el día siguiente. ## Autenticación Todas las peticiones requieren el header `X-API-Key`. Las keys las emite Bun Digital, una por landing/integración, y son revocables de forma independiente. ## ⚠️ La API key es un secreto de servidor La llamada a esta API debe hacerse **siempre desde el backend de la landing**, nunca desde el navegador del visitante. Una key incrustada en JavaScript es pública y puede usarse para inyectar prospectos falsos en el CRM. Ver [guía de integración, §6](/docs/guide#6-buenas-prácticas-de-seguridad). ## Límites 60 peticiones por hora y por API key. Al excederlo se devuelve `429` con el header `Retry-After`. contact: name: Bun Digital url: https://bundigital.com servers: - url: https://bunsale.com/api/public description: Producción tags: - name: Leads description: Captación de prospectos desde landing pages security: - ApiKeyAuth: [] paths: /leads/create-batch: post: tags: [Leads] summary: Registrar varios prospectos en una sola petición operationId: createLeadBatch description: | Igual que `/leads/create`, pero acepta un array de leads en una sola llamada. Pensado para integraciones que acumulan leads del lado del cliente (un cron, un import, un webhook que agrupa eventos) y quieren evitar gastar su cuota de 60 req/hora con un `POST` por lead — esta petición cuenta como **una sola unidad** contra el rate limit, sin importar cuántos leads traiga. Cada lead se valida y procesa de forma **independiente**: uno inválido no bota a los demás. La respuesta trae un resultado por posición, en el mismo orden que el array de entrada. Límite: 50 leads por petición. security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: type: object required: [leads] properties: leads: type: array maxItems: 50 items: $ref: '#/components/schemas/LeadRequest' example: leads: - nombre: María Fernández email: maria@clinicasonrisa.pe telefono: '+51987654321' lead_source: Import CRM anterior - nombre: Carlos Ruiz email: carlos.ruiz@gmail.com lead_source: Import CRM anterior responses: '201': description: Al menos un lead del lote se creó. headers: X-RateLimit-Limit: schema: { type: integer, example: 60 } X-RateLimit-Remaining: schema: { type: integer, example: 59 } content: application/json: schema: $ref: '#/components/schemas/LeadBatchResult' example: success: true created: 1 total: 2 results: - success: true deal_id: 1843 contact_id: 974 company_id: null - success: false error: Payload inválido fields: contacto: Se requiere al menos un email o un teléfono válido para registrar el lead '400': description: Payload inválido a nivel de petición (falta `leads`, no es un array, o excede 50 elementos) — o TODOS los leads del lote fueron rechazados. content: application/json: schema: $ref: '#/components/schemas/LeadBatchResult' '401': description: API key ausente, inexistente o revocada. content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: Se excedió el límite de 60 peticiones por hora para esta API key. content: application/json: schema: $ref: '#/components/schemas/RateLimitError' /leads/status/{deal_id}: get: tags: [Leads] summary: Consultar el estado de un prospecto ya enviado operationId: getLeadStatus description: | Consulta de solo lectura para confirmar que un lead enviado previamente sí llegó a Bunsale y en qué estado está — útil para un cron que reintenta envíos y quiere distinguir "nunca llegó" de "llegó pero está perdido/ganado" sin abrir Bunsale a mano. Solo devuelve deals del **mismo workspace** al que pertenece la API key usada; consultar un `deal_id` de otro workspace responde `404`, igual que si no existiera — nunca se confirma ni se niega su existencia real. security: - ApiKeyAuth: [] parameters: - name: deal_id in: path required: true schema: { type: integer } description: El `deal_id` devuelto por `/leads/create` o `/leads/create-batch`. responses: '200': description: Estado del lead. content: application/json: schema: $ref: '#/components/schemas/LeadStatus' example: success: true data: deal_id: 1842 status: open stage_name: Lead entrante is_won: false is_lost: false lost_reason: null lead_source: Calculadora Meta Ads created_at: '2026-09-10 00:57:47' updated_at: '2026-09-10 00:57:47' actual_close_date: null '401': description: API key ausente, inexistente o revocada. content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: No existe ese `deal_id` en el workspace de esta API key. content: application/json: schema: $ref: '#/components/schemas/Error' example: success: false error: Lead no encontrado '429': description: Se excedió el límite de 60 peticiones por hora para esta API key. content: application/json: schema: $ref: '#/components/schemas/RateLimitError' /leads/create: post: tags: [Leads] summary: Registrar un prospecto operationId: createLead description: | Crea un prospecto en Bunsale. Es idempotente a nivel de **contacto** (si el email o el teléfono ya existen en el workspace, se reutiliza ese contacto en lugar de duplicarlo), pero **no** a nivel de deal: cada petición válida genera un deal nuevo. Si tu landing puede reenviar el formulario, contrólalo del lado de la landing. security: - ApiKeyAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LeadRequest' examples: calculadora: summary: Calculadora de Meta Ads value: nombre: María Fernández empresa: Clínica Dental Sonrisa email: maria@clinicasonrisa.pe telefono: '+51987654321' lead_source: Calculadora Meta Ads utm_source: facebook utm_medium: cpc utm_campaign: calculadora-set-2026 monto_estimado: 4500.00 notas: | Resultado de la calculadora: - Presupuesto mensual: S/ 3,000 - Leads estimados/mes: 42 - CPL estimado: S/ 71 minimo: summary: Solo los campos requeridos value: nombre: Carlos Ruiz email: carlos.ruiz@gmail.com telefono: '+51912345678' lead_source: Landing Colegio San Agustín sin_nombre_de_persona: summary: Integración que solo captura datos de negocio (ej. Google Local Services) value: company: Prueba CRM SL phone: '+34612345678' email: contacto@pruebacrm.es lead_source: Google Local Services Ads city: Madrid website: 'https://pruebacrm.es' responses: '201': description: Prospecto creado headers: X-RateLimit-Limit: description: Peticiones permitidas por hora. schema: type: integer example: 60 X-RateLimit-Remaining: description: Peticiones restantes en la ventana actual. schema: type: integer example: 59 content: application/json: schema: $ref: '#/components/schemas/LeadCreated' example: success: true deal_id: 1842 contact_id: 973 company_id: 214 '400': description: Payload inválido — falta un campo requerido o tiene formato incorrecto. content: application/json: schema: $ref: '#/components/schemas/ValidationError' examples: formato: summary: Formato de campo inválido value: success: false error: Payload inválido fields: email: Formato de email inválido telefono: Formato inválido. Se espera formato internacional, ej. +51987654321 sin_contacto: summary: Falta email y teléfono (se requiere al menos uno) value: success: false error: Payload inválido fields: contacto: Se requiere al menos un email o un teléfono válido para registrar el lead '401': description: API key ausente, inexistente o revocada. content: application/json: schema: $ref: '#/components/schemas/Error' examples: ausente: summary: Falta el header value: success: false error: API key requerida. Envía el header X-API-Key. invalida: summary: Key desconocida value: success: false error: API key inválida revocada: summary: Key revocada por Bun Digital value: success: false error: API key revocada '405': description: Método no permitido. Este endpoint solo acepta POST. content: application/json: schema: $ref: '#/components/schemas/Error' example: success: false error: Método no permitido '429': description: Se excedió el límite de 60 peticiones por hora para esta API key. headers: Retry-After: description: Segundos a esperar antes de reintentar. schema: type: integer example: 812 content: application/json: schema: $ref: '#/components/schemas/RateLimitError' example: success: false error: Límite de 60 peticiones por hora excedido para esta API key. retry_after: 812 '500': description: | Error interno de Bunsale. **No bloquees el formulario del usuario final**: guarda el lead de tu lado y reintenta. Ver guía de integración §5. content: application/json: schema: $ref: '#/components/schemas/Error' example: success: false error: Error interno al registrar el prospecto components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-Key description: | Key emitida por Bun Digital, con el formato `bsk_live_<64 hex>`. Se muestra en claro una sola vez al crearla. Envíala únicamente desde el backend de tu landing. schemas: LeadRequest: type: object required: [lead_source] description: | Se requiere **al menos uno** de `email` o `telefono` (no ambos) para poder contactar al prospecto — si no se envía ninguno de los dos, la API responde `400`. `nombre` es opcional: si no se envía, se usa el nombre de `empresa` como identificador del contacto (útil para integraciones — como Google Local Services Ads — que solo capturan datos de negocio, sin nombre de persona). Además de los nombres de campo documentados aquí, la API reconoce alias comunes de otras integraciones (case-insensitive): `name`, `full_name` o `contact_name` para `nombre`; `company` o `company_name` para `empresa`; `phone` o `tel` para `telefono`. Cualquier otro campo que envíe tu integración y no calce con ninguno de estos nombres **no se descarta** — se guarda igual en la descripción del deal creado, para que nunca se pierda información. properties: nombre: type: string maxLength: 150 nullable: true description: | Nombre completo del prospecto. La primera palabra se guarda como nombre y el resto como apellido. Opcional — si se omite, se usa `empresa` como respaldo. example: María Fernández empresa: type: string maxLength: 150 nullable: true description: Nombre de la empresa. Si ya existe en el workspace se reutiliza; si no, se crea como prospecto. example: Clínica Dental Sonrisa email: type: string format: email maxLength: 150 nullable: true description: | Email del prospecto. Se usa como clave principal para deduplicar contactos. Requerido si no se envía `telefono`. example: maria@clinicasonrisa.pe telefono: type: string pattern: '^\+[1-9]\d{7,14}$' nullable: true description: | Teléfono en formato internacional E.164 (`+` seguido de 8 a 15 dígitos). Los espacios, guiones y paréntesis se eliminan antes de validar, así que `+51 987 654 321` es aceptado. El prefijo de país es obligatorio. Requerido si no se envía `email`. example: '+51987654321' lead_source: type: string maxLength: 100 description: | Identifica la landing o campaña de origen. Usa un valor **estable** por landing — es la dimensión con la que se cruza gasto publicitario contra cierres en los reportes de Bunsale. example: Calculadora Meta Ads utm_source: type: string maxLength: 150 nullable: true example: facebook utm_medium: type: string maxLength: 150 nullable: true example: cpc utm_campaign: type: string maxLength: 150 nullable: true example: calculadora-set-2026 monto_estimado: type: number format: double minimum: 0 nullable: true description: Valor estimado del deal, si la landing puede calcularlo. Se guarda en el campo `amount` del deal. example: 4500.00 notas: type: string maxLength: 5000 nullable: true description: | Contexto libre que verá el vendedor en la descripción del deal (por ejemplo, los resultados de una calculadora). example: 'Presupuesto mensual: S/ 3,000 · CPL estimado: S/ 71' LeadCreated: type: object required: [success, deal_id] properties: success: type: boolean example: true deal_id: type: integer description: ID del deal creado en Bunsale. example: 1842 contact_id: type: integer nullable: true description: ID del contacto creado o reutilizado. example: 973 company_id: type: integer nullable: true description: ID de la empresa creada o reutilizada. `null` si no se envió `empresa`. example: 214 LeadBatchResult: type: object required: [success, created, total, results] properties: success: type: boolean description: 'true si al menos un lead del lote se creó.' created: type: integer description: Cuántos leads del lote se crearon exitosamente. total: type: integer description: Cuántos leads traía el lote. results: type: array description: Un resultado por posición, en el mismo orden que el array `leads` enviado. items: oneOf: - $ref: '#/components/schemas/LeadCreated' - $ref: '#/components/schemas/ValidationError' LeadStatus: type: object required: [success, data] properties: success: type: boolean example: true data: type: object properties: deal_id: type: integer example: 1842 status: type: string enum: [open, won, lost, abandoned] example: open stage_name: type: string example: Lead entrante is_won: type: boolean is_lost: type: boolean lost_reason: type: string nullable: true enum: [precio, ghosting, timing, competencia, otro] lead_source: type: string example: Calculadora Meta Ads created_at: type: string example: '2026-09-10 00:57:47' updated_at: type: string example: '2026-09-10 00:57:47' actual_close_date: type: string nullable: true example: null Error: type: object required: [success, error] properties: success: type: boolean example: false error: type: string description: Mensaje legible del error, en español. ValidationError: allOf: - $ref: '#/components/schemas/Error' - type: object properties: fields: type: object additionalProperties: type: string description: Detalle por campo — la clave es el nombre del campo y el valor el motivo del rechazo. RateLimitError: allOf: - $ref: '#/components/schemas/Error' - type: object properties: retry_after: type: integer description: Segundos a esperar antes de reintentar. example: 812