# Zoosial — API para agentes (llms.txt) > Gateway REST multi-tenant para operar redes sociales (Meta/FB+IG, Threads, TikTok, > Google/YouTube/Business/Calendar, Telegram, WhatsApp) vía APIs oficiales + WaSender. Un agente > puede publicar, gestionar comentarios/DMs, automatizar respuestas, correr campañas, generar > media con IA, leer analíticas y enviar WhatsApp. Base URL (prod): https://zoosial.com Content-Type: application/json en requests con body (UTF-8; acentos y ñ funcionan). Este archivo es el contrato: un test del repo falla si existe un endpoint que no esté aquí. ¿Prefieres herramientas en vez de HTTP? Hay un servidor MCP oficial — ver "MCP" al final. ## Autenticación y modelo de seguridad (LEER PRIMERO) Toda ruta (salvo las públicas de abajo) requiere `Authorization: Bearer `. Tres credenciales: - **API key por owner** (`sk_live_…`): la credencial normal de un agente. Cada key pertenece a UN owner y **solo puede ver/operar los datos de ese owner** (profiles, cuentas, posts, etc. ajenos → 403). Puede tener `scope` (todos los profiles o unos específicos) y `permission` (`read-write` o `read` = solo GET). - **Sesión de usuario (JWT del dashboard)**: la única que puede gestionar API keys y facturación. Una `sk_` NO puede crear ni borrar keys (evita escalada de privilegios). - **API_KEY admin** (una global, del backend): acceso total, bypass del aislamiento. NO se la des a un agente de usuario; es para operación interna. Rutas públicas (sin token): `GET /health`, `GET /llms.txt`, `/webhooks/*` (entrantes de plataformas, validan su propia firma), páginas del sitio y `/docs`, y los callbacks OAuth (`GET /connect/meta/callback` y equivalentes de tiktok/tiktokads/threads/google). Reglas de seguridad que ya aplica el sistema (no hay que reimplementarlas): - **Aislamiento por owner (anti-BOLA):** cada request se acota al owner del token; recurso ajeno → 403, inexistente → 404. - **Least-privilege:** una key `read` no puede POST/PATCH/DELETE (403); una key con scope limitado no toca profiles fuera de su set. - Ningún token de plataforma (OAuth de Meta, etc.) sale nunca en una respuesta. - Errores: `{ "error": "mensaje" }`, más `"code"` cuando el error es de plan/conexión. Status: 400 (validación) · 401 (sin auth / key inválida o expirada) · 403 (no autorizado, o key read-only escribiendo) · 404 · 409 (conflicto) · 422 (la plataforma rechazó la operación: p. ej. la ventana de mensajería de 24 h de Meta ya cerró) · 429 (rate-limit) · 500. ## Gestión de API keys (solo con sesión de usuario, no con `sk_`) - `POST /api-keys` `{ name, expiresIn?(días), scope?('full'|'profiles'), profileIds?, permission?('read-write'|'read'), redirectUris?[] }` → `{ apiKey, key }`. La `key` (`sk_live_…`) se muestra **una sola vez**. - `GET /api-keys` → lista (sin la key en claro). - `PATCH /api-keys/:id` `{ redirectUris?[] }` → edita la key sin recrearla (ver "Conectar cuentas"). - `DELETE /api-keys/:id` → revoca al instante. ## Estructura de datos Owner (usuario) → **Profiles** (marcas/proyectos, ≤1 cuenta por red c/u) → **Accounts** (cuentas conectadas: FB page, IG, WhatsApp…). - `POST /profiles` `{ name }` → `{ profile: { id } }`. - `GET /profiles` → `{ profiles, plan: { status, limits: { maxProfiles, maxAccountsPerProfile } } }`. Cada profile trae `paused: true|false`. - `PATCH /profiles/:id` `{ aiContext }` → único campo editable hoy: el contexto de negocio (máx. 2000 chars) que usa el generador de copy. - `DELETE /profiles/:id` → borra el profile. Solo si está VACÍO: con cuentas o ad accounts dentro responde 409 `{ code: 'profile_not_empty', cuentas, adAccounts }`; desconéctalas primero. Una api key con `scope: 'profiles'` no puede borrar (403), igual que no puede crear. - `GET /accounts[?profileId=]` → cuentas del owner, sin tokens. `DELETE /accounts/:id` → desconecta (borra el registro y el token en Zoosial; no des-registra la app en la plataforma) y emite `account.disconnected`. - `GET /accounts/health` → `{ summary: { total, connected, needsReconnection }, accounts[] }`. - `GET /accounts/:id/about` → `{ about }` con la ficha "Información" de la Página de Facebook (sirve para prellenar `aiContext`). Otras plataformas devuelven `about: null`, no error. - `GET /adaccounts[?profileId=]` → cuentas publicitarias conectadas. `DELETE /adaccounts/:id` → desconecta una. Límites por plan (se cobra por PERFIL, no por cuenta): - Regla estructural: 1 cuenta por red social por perfil (1 Facebook, 1 Instagram, 1 YouTube, 1 Meta ads, 1 Google ads, etc.). Para otra cuenta de la misma red, crea otro perfil. - Free: 1 perfil · 3 cuentas por perfil · 20 copys IA/día · 20 créditos IA/mes. - Trial (14 días desde el registro): 1 perfil · todas las redes (1 c/u) · 200 copys IA/día · 150 créditos IA/mes. - Activo (suscripción de pago): perfiles y cuentas ilimitados · 200 copys IA/día · 300 créditos IA/mes. Errores con `code` en la respuesta: - 409 `one_per_network`: ya hay una cuenta de esa red en el perfil. - 402 `plan_account_limit`: superas el nº de cuentas de tu plan. - 402 `plan_profile_limit`: superas el nº de perfiles de tu plan. - 402 `ai_credit_limit`: no te quedan créditos IA este mes. - 403 `profile_paused`: el perfil quedó pausado (no cubierto por tu plan tras un downgrade o cancelación — el/los perfil(es) más antiguo(s) siguen activos, los demás se pausan). Bloquea publicar/conectar en ese perfil hasta que mejores el plan o liberes otro. ## Conectar cuentas **Si estás construyendo TU PROPIA app sobre esta API (no un uso interactivo tuyo vía Zoosial), tus usuarios finales NUNCA deben pisar zoosial.com ni loguearse ahí — solo deben ver TU interfaz.** **⚠️ Esto SOLO es cierto hoy para Meta (Facebook/Instagram).** Para TikTok, TikTok Ads, Threads y Google (YouTube/Google Business/Calendar) no hay forma de evitarlo — su callback SIEMPRE aterriza en `/app/dashboard` de Zoosial, sin importar qué mandes. No es un parámetro que falte pasar ni un bug a esperar que se arregle solo: hoy el código de Zoosial no implementa el bypass para esas plataformas (ver el detalle de cada una más abajo). Si tu app necesita blanco-etiquetar TikTok o Google, avísanos antes de prometérselo a tus usuarios — no lo asumas por simetría con Meta. Dos requisitos para lograr el flujo de terceros CON META, ambos obligatorios: 1. **`profileId` viene SIEMPRE de `POST /profiles`, nunca lo inventes.** No uses un ID interno tuyo (UUID de tu propio usuario, etc.) como `profileId` — ese profile no existirá en Zoosial y el flujo de conexión fallará (404) en cuanto llegues al picker. Crea el profile primero, guarda el `id` (`prof_…`) que te devuelve, y usa ESE en todo lo demás. 2. **Pasa `redirectUri` (pre-registrado en tu API key, salvo `localhost`/`127.0.0.1`/`::1` que no necesitan registro) + `clientState` (opaco, tuyo) en el `GET /connect/meta`.** Sin `redirectUri`, el callback de Meta cae por default a la pantalla hosted de Zoosial (`/app/connect-select`, exige sesión de Zoosial) — ese es el fallback para uso interactivo directo en zoosial.com, NO para integraciones de terceros. - Meta (FB/IG), flujo white-label completo: 1. `POST /profiles { name }` → `{ profile: { id } }` (guarda `id`, es tu `profileId`). 2. Registra tu `redirectUri` una vez en tu API key: `POST /api-keys { redirectUris:['https://tuapp.com/callback'] }` al crearla, o `PATCH /api-keys/:id { redirectUris }` después (requiere sesión de usuario, no `sk_`). El match es EXACTO (mismo string, sin diferencias de slash ni de esquema). `http://localhost:/...` no necesita este paso, siempre pasa. 3. `GET /connect/meta?profileId=&redirectUri=https://tuapp.com/callback&clientState=lo-que-quieras` → `{ authUrl }`. Mándalo al navegador del usuario. Un `redirectUri` no registrado → 400. 4. Meta redirige a Zoosial, que a su vez redirige a **tu** `redirectUri` (nunca a Zoosial) con `?profileId=...&ct=&platform=meta&clientState=`. `clientState` vuelve intacto: Zoosial no lo interpreta ni lo valida. 5. En tu propia pantalla, con tu `sk_` key y sin sesión de Zoosial (el `ct` vale 15 min y NO se consume, así que puedes encadenar página + cuentas publicitarias en la misma sesión): - Páginas: `GET /profiles/:id/connect/meta/available?ct=` (lista) → `POST /profiles/:id/connect/meta { ct, pageId }` (conecta UNA página + su IG asociado). - Cuentas publicitarias: `GET /profiles/:id/connect/meta/adaccounts/available?ct=` (lista, cada una con `alreadyConnected`) → `POST /profiles/:id/connect/meta/adaccounts { ct, adAccountId }` (`adAccountId` con formato `act_`). No se auto-conecta ninguna: cada una se elige y se cobra aparte. Repetir con la misma ya conectada → 409. - Si omites `redirectUri` (uso interactivo tuyo, no de terceros), cae a `/app/connect-select` — es el comportamiento esperado SOLO en ese caso. - **¿Segunda página de Facebook?** Un profile admite máximo 1 página FB (la 2ª da 409 `one_per_network`). Creá un segundo profile (`POST /profiles`) y repetí el flujo del paso 1-5 ahí — aunque las dos páginas sean del mismo Business Manager, no importa: los leads se leen por página (no por profile ni por cuenta comercial), y el webhook de salida está configurado por dueño, no por profile, así que un solo webhook te sigue entregando los eventos de ambas páginas (cada uno con su `profileId`). - Resto de OAuth — mismo patrón `GET /connect/?profileId=…` → `{ authUrl }`: `GET /connect/tiktok` (orgánico), `GET /connect/tiktokads` (Marketing API), `GET /connect/threads`, `GET /connect/google?product=youtube|googlebusiness|googlecalendar` (**un `product` por conexión, cada uno pide solo su scope**; sin el parámetro asume `youtube`). **Ninguna de estas soporta `redirectUri` todavía** — su callback siempre aterriza en `/app/dashboard` (Zoosial), sin bypass. Si tu app necesita blanco-etiquetar también estas plataformas, avisa antes de ofrecerlas a tus usuarios; hoy solo Meta tiene el flujo de terceros completo. - WhatsApp WaSender (QR): `POST /connect/wasender { profileId, phone, displayName? }` → `{ accountId, qr, status }`. **`phone` son solo dígitos, 8-15, sin `+` ni espacios** (`"34600000000"`); con `+` responde 400. Polling del escaneo: `GET /accounts/:id/wasender/qr`. - WhatsApp Cloud API oficial: `POST /connect/whatsapp-cloud { profileId, phoneNumberId, accessToken, wabaId?, displayName? }`. Un número ya conectado por otro owner → 409. - Telegram: `POST /connect/telegram { profileId, botToken, chatId }` (`chatId` = `@canal` o id numérico; el bot debe ser admin). ## Publicar - `POST /posts { platforms:[{accountId}], content?, mediaUrls?, scheduledFor?(ISO), publishNow? }` → publica ya o programa. Sin `scheduledFor` publica al momento. - `GET /posts?limit=&offset=&profileId=&accountId=` → historial del owner. **`limit` por defecto es 6**: pásalo explícito si quieres más. Sin `profileId`/`accountId`, trae publicaciones de TODOS tus perfiles — un owner con varios perfiles (uno por cliente) debe pasar `profileId` (o `accountId`) para acotar a uno solo. - `DELETE /posts/:id` → cancela una publicación programada o borra un intento fallido. Un post ya `published` responde 400: su contenido vive en la plataforma, se borra con `DELETE /accounts/:id/posts/:objectId`. - `POST /upload` → sube media al CDN y devuelve `{ url }` para `mediaUrls`. **No es multipart**, son dos modos: - JSON: `POST /upload { filename, base64, accountId? }` (el body pasa por el límite de 5 MB de la API — para imágenes). - Binario crudo: `POST /upload?filename=video.mp4&accountId=acc_…` con el `Content-Type` real del archivo y **los bytes como body**; se hace stream directo al CDN sin bufferear, así que no tiene el tope de 5 MB. Es la vía para video. Si ya tienes una URL pública del archivo, úsala directo en `mediaUrls` y sáltate `/upload`. - Cola con slots recurrentes: `POST /queue/slots { profileId, weekday(0-6), time("HH:MM"), timezone(IANA) }` · `GET /queue/slots[?profileId=]` · `DELETE /queue/slots/:id` · `POST /queue { platforms, content?, mediaUrls? }` (auto-agenda al próximo slot libre; 400 si el profile no tiene slots) · `GET /queue`. ## IA generativa (copy, imagen, video, voz) - `GET /ai/models` → catálogo de modelos disponibles `{ id, kind('image'|'video'|'audio'), label, creditCost, validAspectRatios[], maxReferenceImages, durationOptions?, costByDuration? }`. **Descubre los modelos por acá, no hardcodees ids**: un modelo nuevo aparece solo. `maxReferenceImages` es 0 si el modelo no soporta imagen de referencia; `durationOptions` solo está en modelos de video con duración configurable, y cuando el precio escala con la duración viene `costByDuration` (`{ "5": 18, "10": 36 }`) — `creditCost` es el de la duración base. - `POST /ai/generate-copy { profileId, mediaUrl, mediaType('image'|'video'), context? }` → `{ caption, hashtags[] }`. Gratis (rate-limit diario, no gasta crédito), síncrono (rápido). - `POST /ai/generate-image { profileId, prompt, model, aspectRatio?, imageUrls?, presetId? }` → `{ taskId, state:'generating' }`. `imageUrls` (hasta `maxReferenceImages` del modelo) usa el slug `kieModelWithImage` del modelo si existe; `presetId` es solo metadata que vuelve en el historial (no cambia el resultado). - `POST /ai/generate-video { profileId, prompt, model, imageUrls?, aspectRatio?, duration?, presetId? }` → `{ taskId, state:'generating' }`. `duration` solo aplica a modelos con `durationOptions` en `GET /ai/models`. - `POST /ai/generate-audio { profileId, prompt, model, voice, presetId? }` → `{ taskId, state:'generating' }`. Texto a voz. `prompt` es el texto a leer, **tope 500 caracteres** (el costo es fijo, no escala con la longitud). `voice` es obligatorio. `model` debe ser uno con `kind:'audio'` en `GET /ai/models`. - `GET /ai/generate/:taskId` → poll único para imagen, video Y audio (mismo endpoint): `{ state:'generating' }` mientras corre, `{ state:'success', url, creditsUsed }` o `{ state:'fail', error }` al terminar. El crédito se cobra recién al confirmar éxito (fallos no cobran). Tarea inexistente o expirada → 404. - `GET /ai/generations?profileId=` → historial de generaciones del owner (borrado perezoso de las expiradas), `{ generations: AiGeneration[] }` con `AiGeneration = { id, ownerId, profileId, kind, modelId, prompt, presetId?, mediaUrl, creditsUsed, createdAt, expiresAt }`. `profileId` es opcional — sin él devuelve el historial de todos los perfiles del owner. - **Por qué la generación es ASYNC** (crear tarea + poll aparte, nunca una request bloqueada): medido en vivo contra kie.ai, una generación de imagen (Seedream 5.0 Pro) tardó 124s reales — más de lo que aguanta bloqueada una request HTTP detrás de cualquier proxy. - **La URL que devuelve `url` es TEMPORAL** (la sirve kie.ai, no Zoosial — no hay garantía documentada de retención más allá de una ventana corta; kie.ai ni siquiera promete persistencia de los archivos que ELLOS generan más allá de un link de descarga de 20 minutos). Zoosial NO re-sube ese archivo a su propio CDN (Bunny) — usa la URL de kie.ai tal cual, a propósito, para no duplicar infraestructura. Consecuencia práctica: publica o adjunta a un post pronto después de generar; no lo dejes programado para dentro de mucho tiempo sin volver a generarlo antes de esa fecha. ## Gestión orgánica (Meta) — todas requieren una cuenta del owner (`:id` = accountId) - Historial: `GET /accounts/:id/history` · borrar post: `DELETE /accounts/:id/posts/:objectId` - Comentarios: `GET /accounts/:id/comments?objectId=` · `POST /accounts/:id/comments { objectId, message }` · ocultar `POST /accounts/:id/comments/hide { commentId, hide }` (solo Facebook — en Instagram Meta usa otro campo y hoy no oculta nada ahí) · borrar `DELETE /accounts/:id/comments/:commentId` · responder por privado (DM) a demanda, con texto propio: `POST /accounts/:id/comments/:commentId/private-reply { message }` - DMs: `GET /accounts/:id/conversations` · `GET /accounts/:id/conversations/:convId?limit=` · `POST /accounts/:id/messages { recipientId, text }`. Pasadas las 24 h sin respuesta del cliente (y sin la feature "Human Agent" aprobada en Meta) devuelve **422**: el cliente tiene que volver a escribir. - WhatsApp: `POST /accounts/:id/whatsapp/send { to, text }` (enruta por proveedor wasender/cloud; maneja rate-limit) - Insights: `GET /accounts/:id/insights?metric=&period=` · `GET /accounts/:id/post-insights?objectId=` ## Automatizaciones comment-to-DM (tipo ManyChat) - `POST /automations { accountId, platform('facebook'|'instagram'), platformPostId?, keywords?[], dmMessage, publicReply?, active? }` → cuando alguien comenta con una keyword, se le manda un DM (private reply) + respuesta pública opcional, con dedup. Sin `platformPostId` aplica a toda la cuenta; sin `keywords` aplica a todos los comentarios. - `GET /automations` · `GET /automations/:id` · `PATCH /automations/:id` · `DELETE /automations/:id`. - Cada disparo emite el evento `automation.triggered` con `{ automationId, commentId, commenterId, commenterName }`. `commenterName` es el nombre público de quien comentó tal como lo manda Meta; `null` en el puñado de casos en que Meta no lo incluye en el webhook (comentarista con privacidad restringida). ## Leads (Lead Ads) y pipeline - `GET /accounts/:id/lead-forms` (lista) · `GET /accounts/:id/lead-forms/:formId` (detalles + preguntas) · `GET /accounts/:id/leads?formId=…&limit=&after=&since=` (leads paginados por cursor `after`; `after` null = fin). - `POST /accounts/:id/lead-forms` `{ name, locale?, questions[], privacy_policy:{url,link_text}, context_card?, thank_you_page?, follow_up_action_url? }` → crea un formulario en la Página de Facebook (400 si la cuenta no es de Facebook). Meta NO permite editarlo después. - `POST /accounts/:id/lead-forms/:formId/test-webhook` → emite un `lead.received` de MUESTRA (con field_data derivado de las preguntas reales del form) a tus webhooks y devuelve `{ ok, event, webhooksNotified }`. Determinístico, no toca Meta — sirve para probar tu receptor sin esperar un lead real. - Pipeline propio encima del lead (el lead vive en Meta; esto es solo tu capa de estado): `GET /accounts/:id/lead-states` → `{ states }` con **solo los leads que ya tienen estado** — un lead sin fila se pinta como "nuevo", no hay que materializar nada. `PATCH /accounts/:id/lead-states/:leadId { status?, value?, note? }` → `{ state }`. `value` es número o null, `note` texto o null. - Los leads nuevos llegan solos por el evento `lead.received`: no hace falta pollear `/leads` si ya tienes un webhook suscrito. ## Ads (Meta + Google) Todas las rutas de ads se acotan a una cuenta publicitaria tuya: `adAccountId` va en el body (POST/PATCH) o en la query (GET). Una ad account ajena → 403. `adAccountId` acepta tanto nuestro id (`adacc_…`) como el id nativo de la plataforma (`act_…` en Meta) — usa el `act_…` si necesitas un id que sobreviva a un desconectar+reconectar (el `adacc_…` no: se borra junto con el registro). - Campañas: `GET /ads/campaigns?adAccountId=` · `POST /ads/campaigns { adAccountId, ... }` - Ad sets: `GET /ads/adsets?adAccountId=&campaignId=` (`campaignId` opcional: sin él, todos los adsets de la cuenta) · `POST /ads/adsets { adAccountId, ... }` - Anuncios: `GET /ads/ads?adAccountId=&adsetId=` (`adsetId` opcional: sin él, todos los anuncios de la cuenta; el `creative` incluye `body`/`title`/`link_url`/`object_story_spec` con el texto real del anuncio) · `POST /ads/ads { adAccountId, ... }` - Creativos: `POST /ads/adcreatives { adAccountId, name, ... }` → cualquier campo de un AdCreative de Graph, no solo `object_story_id` (reusar un post orgánico existente). Para arte nuevo (no ligado a un post) manda `object_story_spec: { link_data: { message, link, name, picture, ... } }` con una URL de imagen directa en `picture`. Video requiere subir el video a Meta primero (`/act_.../advideos`) para obtener un `video_id` — **eso no está expuesto todavía**, solo `POST /upload` al CDN propio. - Editar/pausar/borrar cualquiera de los anteriores: `PATCH /ads/object { adAccountId, objectId, ...campos }` · `DELETE /ads/object { objectId }`. No es solo para `status`: acepta cualquier campo editable de Graph, incluido `targeting` en un ad set (edades, intereses, geografía), `daily_budget`/`lifetime_budget`, o `creative` en un anuncio (para apuntarlo a un creativo distinto ya creado) — no hay rutas separadas para esto, es el mismo PATCH genérico. No soporta duplicar un objeto existente. - Métricas: `GET /ads/insights?adAccountId=&objectId=&level=&datePreset=&breakdowns=` (objectId requerido; `breakdowns` ej. `age,gender,region` — se pasa tal cual a Meta) - Segmentación: `GET /ads/locations?adAccountId=&q=` (búsqueda de ubicaciones) · `GET /ads/audiences?adAccountId=` · `POST /ads/audiences { adAccountId, ... }` - Píxel y conversiones: `GET /ads/pixels?adAccountId=` · `POST /ads/pixel-events { pixelId, events, testEventCode? }` (Conversions API) · `GET /ads/custom-conversions?adAccountId=` · `POST /ads/custom-conversions { adAccountId, ... }` - Promocionar contenido: `POST /ads/boost { adAccountId, ... }` (impulsa un post existente) · `POST /ads/darkpost { pageId, message, imageUrl }` (post no publicado en el feed, solo para anuncios) - Google Ads: hoy solo `POST /ads/campaigns` sobre una ad account de Google. El resto de esta sección es exclusivo de Meta. ## Commerce / Catálogos (Meta Commerce) Todo pide `adAccountId` de una cuenta de Meta Ads conectada. `GET /commerce/catalogs` devuelve los catálogos asignados a esa cuenta MÁS los del negocio dueño de la cuenta (propios y compartidos), porque un catálogo puede existir en el Business Manager sin estar asignado a ninguna cuenta. - `GET /commerce/catalogs?adAccountId=` · `POST /commerce/catalogs { adAccountId, name, vertical?, businessId? }` (`businessId` es opcional: por defecto se crea en el negocio de la cuenta publicitaria) - `GET /commerce/products` · `POST /commerce/products` · `DELETE /commerce/products` · `POST /commerce/products/batch` (alta/edición masiva) En `POST /commerce/products`, `price` va en unidades de moneda tal cual (`123`, `"123.45"`, `"123,45"` o `"123.45 MXN"`) y `currency` aparte; se convierte a la unidad menor que exige Meta. - `GET /commerce/product-sets` · `POST /commerce/product-sets` · `GET /commerce/shops` ## Google Calendar Requiere una cuenta conectada con `GET /connect/google?product=googlecalendar` (cualquier otra plataforma en `:id` responde 400). Fechas en ISO 8601. - `GET /accounts/:id/calendar/events?timeMin=&timeMax=&maxResults=` → `{ events }` - `POST /accounts/:id/calendar/events { summary, description?, start, end, timeZone? }` → `{ event }` (201) - `PATCH /accounts/:id/calendar/events/:eventId { summary?, description?, start?, end?, timeZone? }` → edición parcial - `DELETE /accounts/:id/calendar/events/:eventId` → `{ ok: true }` ## Analytics para IA - `GET /analytics/:accountId/summary` → `{ followers, reach, impressions, engagement, topPosts, byMetric }` (normalizado para consumo por IA). - `GET /analytics/:accountId/followers` → histórico diario de seguidores (un snapshot por día). ## Webhooks de SALIDA (tus endpoints reciben eventos firmados) - `POST /hooks { url(https), events[], secret? }` → devuelve el `secret` una vez. **Si lo creas con una key de `scope:'profiles'`, el webhook queda acotado a esos mismos profiles** y solo recibe sus eventos; una key sin scope crea un webhook de todo el owner. Esa misma key tampoco ve ni puede tocar los webhooks que cubren profiles fuera de su alcance (403). Máximo 20 webhooks por owner, y `POST /hooks/:id/test` admite un ping cada 30 s. `GET /hooks` · `GET /hooks/:id` · `PATCH /hooks/:id` · `DELETE /hooks/:id` · `POST /hooks/:id/test` (encola un `webhook.test`) · `GET /hooks/:id/deliveries` (historial de entregas con reintentos). - Cada entrega lleva `X-SocialGate-Signature: sha256=` (HMAC del body crudo con tu secret), `X-SocialGate-Event` y `X-SocialGate-Delivery`. Reintentos con backoff. - **Forma del body — el payload va anidado en `data`**, no en la raíz: `{ "event": "message.received", "ownerId": "...", "profileId": "prof_...", "data": { ... } }`. Firma sobre el body crudo tal cual llega (no re-serialices el JSON antes de comparar el HMAC). - Eventos: `post.scheduled`, `post.published`, `post.partial`, `post.failed`, `post.platform.failed`, `account.connected`, `account.needs_reconnection`, `account.disconnected` (lo emite `DELETE /accounts/:id` y `DELETE /adaccounts/:id`), `lead.received`, `message.received`, `automation.triggered`, `webhook.test`. ## Uso / facturación - `GET /usage[?period=YYYY-MM]` → `{ period, connectedAccounts, usage:{ posts_published, messages_sent, … } }`. Contabilidad por owner. - `POST /billing/checkout` (requiere sesión de usuario, no `sk_`) → crea/recupera el Customer de Stripe y una Checkout Session de suscripción; devuelve `{ url }` para redirigir al usuario a pagar. - `POST /billing/portal` (requiere sesión de usuario) → devuelve `{ url }` al Billing Portal de Stripe (gestionar tarjeta, cancelar, ver facturas). 400 si el owner aún no tiene `stripeCustomerId` (nunca inició un checkout). ## MCP (Model Context Protocol) Hay un servidor MCP oficial que envuelve esta API en 36 herramientas para Claude Desktop, Claude Code o cualquier cliente MCP. Se autentica con la misma `sk_live_…`, así que hereda tal cual el aislamiento por owner, el `permission` y el `scope` de la key. **Remoto (recomendado — no hay que instalar nada):** transporte Streamable HTTP en `POST /mcp`. Sin sesiones y sin SSE: cada mensaje JSON-RPC va en su propio POST y la respuesta vuelve como `application/json` (una notificación devuelve 202 sin cuerpo). `GET /mcp` y `DELETE /mcp` responden 405 a propósito — no ofrecemos stream de servidor ni sesiones. ```bash claude mcp add --transport http zoosial https://zoosial.com/mcp \ --header "Authorization: Bearer sk_live_..." ``` - Autenticación: `Authorization: Bearer sk_live_…` en CADA request (aún no hay OAuth; un 401 llega con `WWW-Authenticate: Bearer`). - `MCP-Protocol-Version`: si la mandas debe ser `2025-06-18`, `2025-03-26` o `2024-11-05`; otra → 400. - Se rechaza cualquier request con header `Origin` que no esté en la allowlist (anti DNS rebinding). Los clientes MCP nativos no mandan `Origin`, así que esto solo afecta a navegadores. **Local (stdio):** `mcp/socialgate-mcp.mjs` en el repo, sin dependencias, Node 18+. Mismas herramientas; la key sale de `ZOOSIAL_API_KEY` en el entorno. Guía completa: https://zoosial.com/docs/mcp ## Cómo probar de forma segura 1. Crea un profile: `POST /profiles { name }`. 2. Conecta una cuenta (OAuth Meta, o WhatsApp por QR que no necesita app de desarrollador). 3. Publica o envía; consulta `GET /accounts/health` y `GET /usage`. 4. Para probar tu receptor de webhooks sin datos reales: `POST /hooks/:id/test` y `POST /accounts/:id/lead-forms/:formId/test-webhook`. Todo lo que hagas con una `sk_` key queda acotado a ESE owner: nunca verás ni tocarás datos de otro. Para no gastar de más: los envíos de WhatsApp respetan rate-limit; los webhooks de salida solo van a https.