# Henry Social API

> Versión 1.0.0 · Base URL: `https://publish.henry.cm/api` · OpenAPI: https://publish.henry.cm/api/v1/openapi.json · Referencia interactiva: https://publish.henry.cm/docs

Conecta cuentas de **Facebook, Instagram y TikTok** por OAuth sin registrar nada en los portales de Meta o
TikTok, lee sus métricas y publicaciones, y programa y publica por ellas. Tú te quedas con tu
producto; el servicio se queda con los tokens, las renovaciones, las versiones de las APIs y el App Review.

## Guía rápida

Cinco llamadas para ver las métricas de una cuenta recién conectada. `$KEY` es tu API key.

```bash
# 1. La marca (idempotente por external_ref)
curl -s https://publish.henry.cm/api/v1/profiles -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" -d '{"name":"Marca A","external_ref":"ws-7f3a"}'

# 2. Una sesión de conexión → connect_url
curl -s https://publish.henry.cm/api/v1/oauth/sessions -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"platform":"instagram","return_url":"https://tuapp.com/social/listo","profile":{"external_ref":"ws-7f3a"}}'

# 3. Manda al usuario a connect_url. Vuelve a tu return_url con ?hs_session=…&hs_status=ok&hs_cuentas=1

# 4. Las cuentas de la marca
curl -s "https://publish.henry.cm/api/v1/accounts?profile_id=<profile.id>" -H "Authorization: Bearer $KEY"

# 5. Sus métricas de los últimos 28 días y sus publicaciones
curl -s "https://publish.henry.cm/api/v1/accounts/<account.id>/insights" -H "Authorization: Bearer $KEY"
curl -s "https://publish.henry.cm/api/v1/accounts/<account.id>/posts?limit=25" -H "Authorization: Bearer $KEY"
```

Registra un webhook (`POST /v1/webhooks`) y te enteras cuando una cuenta se conecta, se desconecta o su token
deja de servir, sin consultar.

## Autenticación

Toda llamada a `/v1/*` lleva tu API key:

```
Authorization: Bearer hs_live_…
```

Tu tenant se deriva de la llave. Nunca mandas un `tenant_id`: no existe como parámetro.
Todo lo que lees o escribes queda dentro de tu tenant, en la capa de consulta.

## El modelo

| Recurso | Qué es |
|---|---|
| **profile** | La unidad de aislamiento: tu marca / cliente final. Tú decides el grano. |
| **account** | Una cuenta social conectada (página de FB, IG profesional, cuenta de TikTok). Se asigna a un profile. |
| **grant** | Una autorización de OAuth. Un grant de Meta puede traer N páginas e IGs. No lo manejas directo; aparece en `account.grant_id`. |
| **session** | Un flujo de conexión hospedado, de principio a fin. |

**Los tokens nunca salen por la API.** Tú ves handles y `connected`; el servicio administra los tokens.

## El flujo de conexión

1. `POST /v1/profiles` — crea (o reutiliza, por `external_ref`) el profile de la marca.
2. `POST /v1/oauth/sessions` con `platform`, `return_url` y el `profile` — recibes una `connect_url`.
3. Manda al usuario a `connect_url`. Pasa por el sign-in del proveedor y, si entró por Facebook, elige qué páginas (o qué Instagrams, según `platform`) conectar.
4. El usuario aterriza en tu `return_url` (siempre por **GET**) con `?hs_session=<id>&hs_status=ok|nada&hs_cuentas=<n>`.
   `ok` = conectó `n` cuentas; `nada` = terminó el diálogo sin elegir ninguna.
5. `GET /v1/oauth/sessions/{id}` — ves qué cuentas resultaron. O `GET /v1/accounts?profile_id=…`.

**Si algo falla** (canceló en el proveedor, tardó más de 30 min, la cuenta no tiene páginas…) el usuario **no vuelve a tu
`return_url`**: ve una página del servicio con el motivo y un "cierra esta ventana". Tú lo ves en
`GET /v1/oauth/sessions/{id}` → `status: "failed" | "expired"` y `error`: `state_vencido`, `sin_paginas`,
`meta:<error>:<reason>` / `instagram:…` / `tiktok:…` (lo que devolvió el proveedor; `access_denied`/`user_denied` = canceló), o `finalize`.
No hay evento de webhook para sesiones: si tu UI espera el regreso, dale un tiempo máximo y consulta la sesión.

**`return_url` es tuya y no se registra en ningún portal.** Es un parámetro; el redirect URI de OAuth es del servicio.
Acepta `https`, o `http` solo en `localhost`.

### Instagram: dos formas de entrar

| `login_method` | El usuario entra con | Requiere página de FB | Para quién |
|---|---|---|---|
| `instagram` (default) | su cuenta de **Instagram** | No | El dueño de la cuenta; Business y Creator |
| `facebook` | su cuenta de **Facebook** | Sí | Agencias que administran la página del cliente sin su contraseña de IG |

Son dos productos distintos de Meta con credenciales distintas; el servicio maneja los dos. Una cuenta de
IG conectada por cualquiera de los dos caminos se ve igual en la API.

### Modo agency

Si omites `profile` en la sesión, el usuario conecta una vez y las cuentas quedan **sin asignar**. Después las
repartes con `PATCH /v1/accounts/{id}`. Sirve para que un solo login de Facebook cubra a todos los clientes de
una agencia: cuando ese login expira, se renueva uno y se sanan todos.

## Webhooks

Registra un endpoint con `POST /v1/webhooks` y recibes un POST firmado por cada evento:

| Evento | Cuándo | `data` |
|---|---|---|
| `account.connected` | Una cuenta quedó conectada (uno por cuenta) | `{ account, session_id }` |
| `account.updated` | Cambió su profile (PATCH) | `{ account, changed }` |
| `account.disconnected` | La desconectaste, el usuario quitó la app, o el token murió | `{ account, reason }` |
| `account.health_changed` | Cambió `token_health` | `{ account, previous, current }` |
| `job.published` | Todos los legs del job publicaron | `{ job }` |
| `job.partial` | Algunos legs publicaron, otros fallaron | `{ job }` |
| `job.failed` | Ningún leg publicó | `{ job }` |
| `ping` | `POST /v1/webhooks/{id}/test` | `{ webhook_id }` |

El cuerpo es siempre `{ "id": "<id de entrega>", "event": "<evento>", "created_at": "<ISO>", "data": { … } }`.
`account` dentro de `data` es el mismo objeto de la API más `profile: { id, external_ref, name }`, para que
no tengas que hacer un lookup. `reason` en `account.disconnected`: `api` (lo pediste tú), `deauthorized_by_user`
(el usuario quitó la app en Meta), `token_expired`, o `graph:<code>` / `instagram:<code>` / `tiktok:<code>`
(el proveedor rechazó el token; lo detectó el barrido de salud o un refresh).

**Firma.** Cada entrega lleva `X-HenrySocial-Signature: t=<unix>,v1=<hex>` donde
`v1 = HMAC-SHA256(secret, "<t>.<raw body>")`. Verifica sobre el **cuerpo crudo**, no sobre
un JSON re-serializado, y rechaza si `|ahora - t| > 300 s`. También llegan
`X-HenrySocial-Event` y `X-HenrySocial-Delivery` (id único: deduplica por él).

**Entrega.** Responde 2xx en < 10 s. Si no, se reintenta con backoff durante ~10 h. Tras 20
entregas muertas seguidas el endpoint se desactiva; lo reactivas con `PATCH {active:true}`.
Un evento nunca se pierde por un fallo del transporte: se registra antes de enviarse.
Registrar dos veces la misma URL crea dos endpoints (y dos entregas por evento): lista antes de crear.

## Insights

Lectura **en vivo** contra Meta / TikTok, con el token de la cuenta: `GET /v1/accounts/{id}/insights` (resumen
del periodo) y `GET /v1/accounts/{id}/posts` (publicaciones con métricas). Lo que el proveedor no da va en
`null`, nunca en 0. Cada llamada tarda 1–4 s: cachea de tu lado. El periodo por defecto son los últimos 28 días;
el máximo, 90 (para más atrás, guarda lo que lees).

### Métricas: qué son y de dónde salen

Los nombres son los que las plataformas entregan **hoy** (Graph v26), no los de hace dos años. Meta retiró
`impressions` en 2025 y lo que queda es `views` (la misma cuenta que muestran sus apps); ningún proveedor
puede dar ya impresiones o alcance de un **post de página de Facebook**. Cada número viene de una sola llamada
al proveedor; nada se estima.

| Métrica | Instagram | Facebook (página) | TikTok |
|---|---|---|---|
| `followers` | `followers_count` | `followers_count` | `follower_count` |
| `totals.reach` | `reach` del periodo | — | — |
| `totals.views` | `views` (antes *impressions*) | — | — |
| `totals.profile_views` / `accounts_engaged` / `interactions` / `website_clicks` | mismos nombres (`total_interactions` → `interactions`) | — | — |
| `totals.likes` / `comments` / `shares` / `saves` | del periodo | — | `likes` de vida |
| `totals.post_engagements` / `page_views` / `follows` / `video_views` / `reactions` | — | `page_post_engagements`, `page_views_total`, `page_daily_follows_unique`, `page_video_views`, `page_actions_post_reactions_total` | — |
| `daily[]` | `reach`, `new_followers` por día | las cinco de arriba por día | — (TikTok no da series) |
| Post: `likes` / `comments` / `shares` / `saves` | `likes`, `comments`, `shares`, `saved` | `post_reactions_by_type_total.like`, `post_activity_by_action_type.comment`, `shares.count` | `like_count`, `comment_count`, `share_count` |
| Post: `reach` / `views` | `reach`, `views` (feed y reels) | **no existe** | `view_count` (solo `views`) |
| Post: `reactions` / `clicks` / `video_views` | — | `post_reactions_by_type_total` (todas), `post_clicks`, `post_video_views` (de vida) | — |
| Post: `interactions` / `duration_s` | `total_interactions` | — | `duration` |

Si vienes de otro proveedor: `impressions` ↔ `views`; *engagement rate* no se entrega, calcúlalo tú con la
fórmula que uses hoy (`(likes + comments + shares + saves) / followers` o sobre `reach`); los seguidores son el
conteo de **hoy** — guarda un snapshot diario si quieres crecimiento.

### Contenido

Cada post trae `permalink` (la URL pública en la red), `caption`, `type`, `published_at` y `thumbnail_url`
(en Instagram también `media_url`). Las URLs de imagen/video son del CDN de la plataforma y **caducan en horas**;
para un reporte guardado o una galería, descarga la imagen cuando la recibes y sirve tu copia. En Facebook,
`thumbnail_url` es `full_picture` (la imagen principal; para álbumes, la primera). TikTok da la portada del video.

## Errores

Siempre `{ "error": "<código>", "detail"?: … }`. Ramifica por `error`, nunca por `detail`. Códigos
transversales: `unauthorized` (401), `invalid_id` (400, un id de la ruta no es UUID), `invalid_json` /
`invalid_body` (400), `not_found` (404), `internal` y `*_failed` (500, reintentable).

## Convenciones

Fechas en ISO 8601 UTC. Ids son UUID. Sin límite de tasa publicado hoy; reintenta 5xx con backoff.
Las API keys las emite Henry por cada tenant (no hay autoservicio aún); si pierdes la tuya se revoca y se emite otra.
Los permisos que el usuario otorga son los mínimos para conectar, leer métricas y publicar; el servicio no lee
mensajes ni comentarios de terceros. Versionado: la ruta lleva `/v1`; un cambio incompatible sería `/v2`, y lo
que se agrega dentro de `/v1` (campos, métricas nuevas) no rompe lo que ya lees.

## Publicar

`POST /v1/jobs` programa (o publica ahora) un post en una o varias cuentas de la misma marca. Tres reglas:

1. **Handoff temprano.** Entregas el job al programar, con payload autocontenido (caption, URLs https de media,
   cuentas destino). Desde ese instante el reloj es del servicio: si tu sistema se cae, el post sale igual. El
   media se copia al bucket del servicio al despachar; tu storage deja de importar.
2. **Cancelar siempre se confirma.** `DELETE` devuelve `confirmed` (no va a salir), `in_flight` (ya se está
   publicando; consulta el resultado) o `already_published`. Nunca fire-and-forget.
3. **Idempotencia por contenido.** Único por `(external_ref, content_hash)`. Repetir el mismo POST devuelve el
   mismo job (200) en vez de crear otro (201). Si mandas tu propio `content_hash` (sha256 de tu post), úsalo
   para que reprogramar no cambie el hash y editar sí. Si el job anterior falló o se canceló, repetir el POST lo
   re-agenda con los legs en cero: es "inténtalo otra vez".

Cada **leg** es una cuenta destino con su `post_type` (`text` solo Facebook; `image`, `carousel` hasta 10,
`video`, `reel`, `story`). Si no lo mandas se infiere del media; `story` hay que pedirlo. Instagram exige
media; los videos de Instagram se publican como reels. Una **story** con varios media publica una story por
elemento, **en orden** (la siguiente sale cuando la anterior ya existe); el leg devuelve `external_post_ids`.
En Facebook los reels van por `/video_reels` y las stories por la Page Stories API. Con `payload.settings.instagram`
mandas portada del reel (`cover_url` o `thumb_offset_ms`), `share_to_feed` y `collaborators`. Un job termina `published` (todos), `partial` (algunos) o `failed` (ninguno), y te avisa por webhook
`job.published` / `job.partial` / `job.failed` con el job completo en `data.job`. Si prefieres consultar,
`GET /v1/jobs/{id}`: cada leg trae `external_post_id`, `permalink` y, si falló, `error.code` (el código
numérico del proveedor) y `error.message`.

Reintentos: una publicación que falla por red o rate limit se reintenta sola dentro del despacho; lo que Meta
rechaza por contenido (media inválido, cuota de 100 posts/24 h de Instagram) queda `failed` con su código.
Un job a medio publicar tras un fallo del servicio se reanuda sin duplicar: lo que ya existe en la red se adopta.

### TikTok

Direct Post de **video** (un archivo) o de **fotos** (1 a 10 en un post; TikTok admite hasta 35, el tope del job
es 10). Sin texto solo ni stories. TikTok publica **asíncrono**: acepta el post, descarga el media del bucket del
servicio (dominio verificado en TikTok), lo procesa y lo modera — normalmente en un minuto, a veces horas. El leg
espera hasta 3 minutos; si TikTok tarda más, queda `published` con el `publish_id` como `external_post_id` y
el servicio vuelve a emitir `job.*` cuando TikTok avisa por webhook (id público, o `failed` si la moderación
lo tiró). Un post **SELF_ONLY** no tiene id público ni `permalink`: TikTok solo los da para posts públicos.

`payload.settings.tiktok` es **obligatorio** para un leg de TikTok y `privacy_level` no tiene default: la
guía de UX de TikTok exige que lo elija el usuario de entre las opciones que devuelve
`GET /v1/accounts/{id}/creator-info` (consúltalo al abrir tu editor; también dice si la cuenta tiene
comentarios/duet/stitch apagados y si puede publicar ahora). Mientras la app no esté auditada por TikTok
(`audited: false` en creator-info), **solo SELF_ONLY** está disponible y cualquier otro valor se fuerza a
SELF_ONLY: avísalo en tu UI. `allow_comment`/`allow_duet`/`allow_stitch` van apagados si no los mandas
(TikTok exige casillas apagadas por defecto); `brand_content_toggle` (partnership pagado, no puede ser
SELF_ONLY) y `brand_organic_toggle` (promoción de la propia marca) son la declaración de contenido comercial;
`is_aigc` etiqueta contenido generado con IA. El caption es el `title` del video (≤ 2200) o la
`description` de las fotos (≤ 4000); `settings.tiktok.title` (≤ 90) es el encabezado opcional de las fotos.
Errores de TikTok en `error.code` (el texto trae el código textual de TikTok): 190 token inválido ·
7001 invalid_param · 7002 privacy_level_option_mismatch · 7003 unaudited_client_can_only_post_to_private_accounts ·
7004 url_ownership_unverified · 7005 spam_risk_too_many_posts (límite diario de la cuenta) · 7006 usuario bloqueado ·
7007 reached_active_user_cap (tope de la app, se reintenta) · 7008 demasiados pendientes · 7009 scope_not_authorized
(reconectar) · 7011 rate_limit_exceeded · 7021–7024 formato/duración/fps/tamaño rechazado · 7025 internal ·
7026/7027 TikTok no pudo descargar el media · 7028 cancelado · 7029 auth_removed · 7030/7031 spam · 7040 falta
`privacy_level` · 7041 media inválido para TikTok · 7099 otro.

## Próximamente

Inbox (mensajes y comentarios).

## Referencia de endpoints

Todas las rutas van bajo `https://publish.henry.cm/api` y llevan `Authorization: Bearer <API key>`.

## Profiles — Tus marcas / clientes finales.

### GET /api/v1/profiles — Listar profiles

**Parámetros**

- `limit` (query) — integer (default 100)
- `offset` (query) — integer (default 0)

**Respuestas**

- `200` — OK · object
  - `profiles` — array<object>
    - `id` — string (uuid), requerido
    - `name` — string, requerido
    - `external_ref` — string (nullable), requerido: Tu identificador de esta marca (p. ej. el id de tu workspace). Hace idempotente la creación.
    - `created_at` — string (date-time), requerido
  - `total` — integer
  - `limit` — integer
  - `offset` — integer
- `401` — API key ausente, inválida o revocada. · → Error

### POST /api/v1/profiles — Crear un profile

Con `external_ref` es idempotente: repetir devuelve el mismo profile (200) en vez de duplicarlo. Sin él, crea uno nuevo cada vez (201).

**Body** (application/json, requerido)

- `name` — string, requerido
- `external_ref` — string: Tu id de esta marca (p. ej. el id de tu workspace).

Ejemplo:

```json
{
  "name": "Marca A",
  "external_ref": "ws-7f3a…"
}
```

**Respuestas**

- `200` — Ya existía (por `external_ref`). · object
  - `profile` — object
    - `id` — string (uuid), requerido
    - `name` — string, requerido
    - `external_ref` — string (nullable), requerido: Tu identificador de esta marca (p. ej. el id de tu workspace). Hace idempotente la creación.
    - `created_at` — string (date-time), requerido
- `201` — Creado. · object
  - `profile` — object
    - `id` — string (uuid), requerido
    - `name` — string, requerido
    - `external_ref` — string (nullable), requerido: Tu identificador de esta marca (p. ej. el id de tu workspace). Hace idempotente la creación.
    - `created_at` — string (date-time), requerido
- `400` — `invalid_body` / `invalid_json`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error

### GET /api/v1/profiles/{id} — Ver un profile con sus cuentas

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del profile

**Respuestas**

- `200` — OK · object
  - `profile` — object
    - `id` — string (uuid), requerido
    - `name` — string, requerido
    - `external_ref` — string (nullable), requerido: Tu identificador de esta marca (p. ej. el id de tu workspace). Hace idempotente la creación.
    - `created_at` — string (date-time), requerido
  - `accounts` — array<object>
    - `id` — string (uuid), requerido
    - `platform` — "facebook" | "instagram" | "tiktok", requerido
    - `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
    - `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
    - `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
    - `username` — string (nullable), requerido
    - `display_name` — string (nullable), requerido
    - `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
    - `connected` — boolean, requerido
    - `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
    - `disconnected_at` — string (date-time, nullable), requerido
    - `created_at` — string (date-time), requerido
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

### DELETE /api/v1/profiles/{id} — Borrar un profile

**No borra sus cuentas**: quedan sin asignar. Se rechaza (409) si el profile tiene jobs programados.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del profile

**Respuestas**

- `200` — Borrado. · object
  - `deleted` — boolean
  - `id` — string
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error
- `409` — `profile_has_scheduled_jobs` — cancela o espera esos jobs primero. · → Error

## OAuth — Sesiones de conexión hospedadas.

### POST /api/v1/oauth/sessions — Iniciar una conexión

Devuelve la `connect_url` a la que mandas al usuario. Expira en 30 minutos. `facebook` abre el diálogo de Facebook y luego un selector de páginas: conecta **solo páginas** (el Instagram vinculado se conecta con `platform: "instagram"`, que tiene su propio flujo). `instagram` abre Instagram Login (instagram.com) o, con `login_method: "facebook"`, el diálogo de Facebook con un selector solo de Instagrams. `tiktok` abre TikTok.

**Body** (application/json, requerido)

- `platform` — "facebook" | "instagram" | "tiktok", requerido
- `login_method` — "instagram" | "facebook": Solo para instagram. Default `instagram` (instagram.com, sin página de FB). `facebook` = vía la página, con selector.
- `return_url` — string (uri), requerido: A dónde vuelve el usuario al terminar. https (o http en localhost). Recibe `?hs_session=&hs_status=`.
- `profile` — object: Omítelo para modo agency (cuentas sin asignar). Crea el profile si no existe (idempotente por `external_ref`).
  - `external_ref` — string, requerido
  - `name` — string: Nombre del profile si se crea. (`nombre` sigue aceptándose como alias.)

Ejemplo:

```json
{
  "platform": "instagram",
  "return_url": "https://app.tuherramienta.com/cuentas?connected=1",
  "profile": {
    "external_ref": "ws-7f3a…",
    "name": "Marca A"
  }
}
```

**Respuestas**

- `201` — Sesión creada. · object
  - `session_id` — string (uuid)
  - `connect_url` — string (uri)
  - `expires_at` — string (date-time)
- `400` — `invalid_body` / `invalid_return_url`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error

### GET /api/v1/oauth/sessions/{id} — Ver una sesión y las cuentas que resultaron

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la sesión (`hs_session` en tu return_url)

**Respuestas**

- `200` — OK · object
  - `session` — object
    - `id` — string (uuid), requerido
    - `platform` — "facebook" | "instagram" | "tiktok", requerido
    - `profile_id` — string (uuid, nullable), requerido
    - `status` — "pending" | "completed" | "expired" | "failed", requerido
    - `error` — string (nullable), requerido: Solo con `failed`: `state_vencido`, `sin_paginas`, `finalize`, o `meta:<error>:<reason>` / `instagram:…` / `tiktok:…` tal como lo devolvió el proveedor.
    - `return_url` — string (uri), requerido
    - `accounts` — array<object>, requerido: Las cuentas que resultaron de esta sesión (vacío hasta que se complete).
      - `id` — string (uuid), requerido
      - `platform` — "facebook" | "instagram" | "tiktok", requerido
      - `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
      - `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
      - `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
      - `username` — string (nullable), requerido
      - `display_name` — string (nullable), requerido
      - `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
      - `connected` — boolean, requerido
      - `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
      - `disconnected_at` — string (date-time, nullable), requerido
      - `created_at` — string (date-time), requerido
    - `expires_at` — string (date-time), requerido
    - `created_at` — string (date-time), requerido
    - `completed_at` — string (date-time, nullable), requerido
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

## Accounts — Cuentas sociales conectadas.

### GET /api/v1/accounts — Listar cuentas

Sin filtros devuelve todas las del tenant, incluidas las sin asignar. `profile_id=unassigned` filtra solo ésas. Sin paginación: viene la lista completa.

**Parámetros**

- `profile_id` (query) — string: UUID de un profile, o `unassigned`.
- `platform` (query) — "facebook" | "instagram" | "tiktok"
- `connected` (query) — boolean

**Respuestas**

- `200` — OK · object
  - `accounts` — array<object>
    - `id` — string (uuid), requerido
    - `platform` — "facebook" | "instagram" | "tiktok", requerido
    - `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
    - `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
    - `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
    - `username` — string (nullable), requerido
    - `display_name` — string (nullable), requerido
    - `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
    - `connected` — boolean, requerido
    - `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
    - `disconnected_at` — string (date-time, nullable), requerido
    - `created_at` — string (date-time), requerido
  - `total` — integer
- `400` — `invalid_platform`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error

### GET /api/v1/accounts/{id} — Ver una cuenta

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la cuenta

**Respuestas**

- `200` — OK · object
  - `account` — object
    - `id` — string (uuid), requerido
    - `platform` — "facebook" | "instagram" | "tiktok", requerido
    - `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
    - `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
    - `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
    - `username` — string (nullable), requerido
    - `display_name` — string (nullable), requerido
    - `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
    - `connected` — boolean, requerido
    - `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
    - `disconnected_at` — string (date-time, nullable), requerido
    - `created_at` — string (date-time), requerido
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

### PATCH /api/v1/accounts/{id} — Asignar la cuenta a un profile

Manda `profile_id` (UUID, o `null` para desasignar) o `profile_external_ref` (tu id). El profile debe ser de tu tenant.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la cuenta

**Body** (application/json, requerido)

- `profile_id` — string (uuid, nullable)
- `profile_external_ref` — string

Ejemplo:

```json
{
  "profile_external_ref": "ws-7f3a…"
}
```

**Respuestas**

- `200` — OK · object
  - `account` — object
    - `id` — string (uuid), requerido
    - `platform` — "facebook" | "instagram" | "tiktok", requerido
    - `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
    - `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
    - `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
    - `username` — string (nullable), requerido
    - `display_name` — string (nullable), requerido
    - `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
    - `connected` — boolean, requerido
    - `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
    - `disconnected_at` — string (date-time, nullable), requerido
    - `created_at` — string (date-time), requerido
- `400` — `invalid_body`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — `not_found` (la cuenta) o `profile_not_found`. · → Error

### DELETE /api/v1/accounts/{id} — Desconectar una cuenta

**Nunca borra la fila.** La cuenta queda `connected: false`, su token se elimina y el id sigue existiendo para que puedas mostrar "reconecta esta cuenta". Idempotente. Se rechaza (409) si tiene publicaciones en vuelo.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la cuenta

**Respuestas**

- `200` — Desconectada (o ya lo estaba). · object
  - `account` — object
    - `id` — string (uuid), requerido
    - `platform` — "facebook" | "instagram" | "tiktok", requerido
    - `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
    - `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
    - `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
    - `username` — string (nullable), requerido
    - `display_name` — string (nullable), requerido
    - `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
    - `connected` — boolean, requerido
    - `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
    - `disconnected_at` — string (date-time, nullable), requerido
    - `created_at` — string (date-time), requerido
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error
- `409` — `account_has_pending_legs`. · → Error

### GET /api/v1/accounts/{id}/health — Chequeo vivo del token

Le pregunta al proveedor, no a la base. Si el token murió, lo anota en `token_health` para que la cuenta deje de parecer sana antes de que una publicación truene.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la cuenta

**Respuestas**

- `200` — OK · object
  - `id` — string
  - `status` — "healthy" | "unhealthy" | "disconnected"
  - `token_valid` — boolean
  - `issues` — array<string>: `disconnected`, `no_token`, `token_expired`, `token_invalid`, `permissions`, `provider_unreachable`, `graph_<code>`, `tiktok_<code>`.
  - `checked_at` — string (date-time)
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

### GET /api/v1/accounts/{id}/creator-info — TikTok: lo que exige la guía de UX antes de publicar

Solo cuentas de TikTok. Opciones de privacidad de ESA cuenta (tu selector no debe traer valor por defecto), si tiene comentarios/duet/stitch apagados (deshabilita esas casillas), duración máxima de video, y si puede publicar ahora. Con `audited: false` solo hay SELF_ONLY. Consúltalo al abrir el editor, no lo caches más de unos minutos.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la cuenta

**Respuestas**

- `200` — OK · object
  - `account_id` — string
  - `creator` — object
    - `username` — string (nullable)
    - `nickname` — string (nullable)
    - `avatar_url` — string (nullable): Vence en 2 horas.
  - `privacy_level_options` — array<string>: Lo que puedes ofrecer. Sin auditoría: solo SELF_ONLY.
  - `privacy_level_options_all` — array<string>: Lo que TikTok devolvió, sin filtrar.
  - `comment_disabled` — boolean
  - `duet_disabled` — boolean
  - `stitch_disabled` — boolean
  - `max_video_post_duration_sec` — integer (nullable)
  - `audited` — boolean: false = la app aún no pasa la auditoría de Content Posting: los posts salen privados.
  - `can_post` — boolean: false = TikTok no deja publicar ahora (límite diario, cuenta bloqueada, tope de creadores de la app).
  - `block` — object (nullable)
    - `code` — integer
    - `tiktok_code` — string
    - `message` — string
  - `fetched_at` — string (date-time)
- `400` — `platform_not_supported` — no es TikTok. · → Error
- `401` — API key ausente, inválida o revocada. · → Error
- `403` — `provider_permissions` — la cuenta se conectó sin `video.publish`; reconéctala. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error
- `409` — `account_disconnected` / `token_invalid`. · → Error
- `502` — `provider_error`. · → Error

## Webhooks — Endpoints a los que se avisa de cada cambio.

### GET /api/v1/webhooks — Listar endpoints

**Respuestas**

- `200` — OK · object
  - `webhooks` — array<→ Webhook>
- `401` — API key ausente, inválida o revocada. · → Error

### POST /api/v1/webhooks — Registrar un endpoint

El `secret` (`whsec_…`) se devuelve **una sola vez**. Sin `events`, recibe todos. No es idempotente por URL.

**Body** (application/json, requerido)

- `url` — string (uri), requerido: https (o http en localhost).
- `events` — array<"ping" | "account.connected" | "account.updated" | "account.disconnected" | "account.health_changed">

Ejemplo:

```json
{
  "url": "https://app.tuherramienta.com/webhooks/henry-social"
}
```

**Respuestas**

- `201` — Creado. · object
  - `webhook` — → Webhook
  - `secret` — string: Guárdalo ahora.
- `400` — `invalid_body`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error

### GET /api/v1/webhooks/{id} — Ver un endpoint y sus últimas 20 entregas

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del endpoint

**Respuestas**

- `200` — OK · object
  - `webhook` — → Webhook
  - `recent_deliveries` — array<object>
    - `id` — string
    - `event` — string
    - `status` — "pending" | "delivered" | "dead"
    - `attempts` — integer
    - `last_http_status` — integer (nullable)
    - `last_error` — string (nullable)
    - `created_at` — string
    - `delivered_at` — string (nullable)
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

### PATCH /api/v1/webhooks/{id} — Cambiar URL o eventos, o reactivar

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del endpoint

**Body** (application/json, requerido)

- `url` — string (uri)
- `events` — array<string>
- `active` — boolean

Ejemplo:

```json
{
  "active": true
}
```

**Respuestas**

- `200` — OK · object
  - `webhook` — → Webhook
- `400` — `invalid_body`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

### DELETE /api/v1/webhooks/{id} — Borrar un endpoint

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del endpoint

**Respuestas**

- `200` — Borrado. · object
  - `deleted` — boolean
  - `id` — string
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

### POST /api/v1/webhooks/{id}/test — Mandar un ping

Encola una entrega `ping`. Revisa el resultado en `GET /v1/webhooks/{id}`.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del endpoint

**Respuestas**

- `202` — Encolado. · object
  - `delivery_id` — string
  - `dispatched` — boolean
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error
- `409` — `webhook_inactive`. · → Error

## Insights — Métricas de cuenta y de publicaciones, leídas en vivo del proveedor.

### GET /api/v1/accounts/{id}/insights — Resumen de la cuenta en un periodo

Seguidores de hoy, totales del periodo y serie diaria (donde el proveedor la da). En vivo: 1–4 s. Default: últimos 28 días; tope 90.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la cuenta
- `from` (query) — string (date): YYYY-MM-DD (UTC). Default: `to` − 27 días.
- `to` (query) — string (date): YYYY-MM-DD (UTC). Default: hoy.

**Respuestas**

- `200` — OK · → Insights
- `400` — `invalid_period`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error
- `403` — `provider_permissions` — el token no trae el permiso (p. ej. la app aún sin acceso avanzado). · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error
- `409` — `account_disconnected` / `token_invalid` — reconecta la cuenta. · → Error
- `502` — `provider_error` — `detail.code` trae el código del proveedor. · → Error

### GET /api/v1/accounts/{id}/posts — Publicaciones con métricas

Del más reciente al más viejo, dentro del periodo. `cursor` = el `next_cursor` de la página anterior. TikTok no filtra por fecha del lado del proveedor: se pagina hasta salir del periodo (máx. 100 videos).

**Parámetros**

- `id` (path, requerido) — string (uuid): Id de la cuenta
- `from` (query) — string (date)
- `to` (query) — string (date)
- `limit` (query) — integer (default 25)
- `cursor` (query) — string

**Respuestas**

- `200` — OK · object
  - `account_id` — string (uuid)
  - `platform` — "facebook" | "instagram" | "tiktok"
  - `period` — object
    - `from` — string (date)
    - `to` — string (date)
  - `source` — string
  - `posts` — array<→ Post>
  - `next_cursor` — string (nullable)
  - `fetched_at` — string (date-time)
- `400` — `invalid_period` / `invalid_limit`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error
- `403` — `provider_permissions`. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error
- `409` — `account_disconnected` / `token_invalid`. · → Error
- `502` — `provider_error`. · → Error

## Jobs — Publicaciones: programar, consultar, re-agendar y cancelar.

### GET /api/v1/jobs — Listar jobs

**Parámetros**

- `profile_id` (query) — string (uuid)
- `external_ref` (query) — string
- `status` (query) — string: Uno o varios separados por coma.
- `limit` (query) — integer (default 50)
- `offset` (query) — integer (default 0)

**Respuestas**

- `200` — Los más recientes primero. · object
  - `jobs` — array<object>
    - `id` — string (uuid), requerido
    - `external_ref` — string, requerido: Tu id del post (p. ej. content_posts.id).
    - `profile_id` — string (uuid), requerido
    - `status` — "scheduled" | "dispatching" | "published" | "partial" | "failed" | "canceled", requerido: `partial` = algunos legs publicaron y otros fallaron.
    - `scheduled_at` — string (date-time), requerido
    - `content_hash` — string, requerido: sha256 del contenido normalizado. Con `external_ref` hace único al job.
    - `payload` — object, requerido
      - `caption` — string
      - `media` — array<object>
        - `url` — string (uri)
        - `type` — "image" | "video"
      - `link` — string (uri)
      - `settings` — object
    - `legs` — array<object>, requerido
      - `id` — string (uuid), requerido
      - `account_id` — string (uuid), requerido
      - `platform` — "facebook" | "instagram" | "tiktok", requerido
      - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
      - `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
      - `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
      - `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
      - `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
      - `attempts` — integer, requerido
      - `error` — object (nullable), requerido
        - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
        - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
      - `started_at` — string (date-time, nullable)
      - `published_at` — string (date-time, nullable), requerido
      - `updated_at` — string (date-time)
    - `created_at` — string (date-time), requerido
    - `dispatched_at` — string (date-time, nullable)
    - `completed_at` — string (date-time, nullable)
    - `canceled_at` — string (date-time, nullable)
    - `updated_at` — string (date-time)
  - `total` — integer
  - `limit` — integer
  - `offset` — integer
- `400` — `invalid_status`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error

### POST /api/v1/jobs — Programar o publicar ahora

Sin `scheduled_at` publica ahora. Idempotente por `(external_ref, content_hash)`: repetir devuelve el mismo job (200). Máximo 90 días adelante, 20 legs, 10 media.

**Body** (application/json, requerido)

- `external_ref` — string, requerido
- `profile_id` — string (uuid), requerido
- `scheduled_at` — string (date-time): Omitir = ahora. Un pasado reciente se trata como ahora.
- `content_hash` — string: Opcional: tu sha256 del contenido. Si falta, se calcula sobre caption + media + legs.
- `legs` — array<object>, requerido
  - `account_id` — string (uuid), requerido
  - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story": Opcional; se infiere del media (`story` hay que pedirlo).
- `payload` — object, requerido
  - `caption` — string, requerido
  - `media` — array<object>
    - `url` — string (uri), requerido: https pública. Se copia al bucket del servicio al despachar.
    - `type` — "image" | "video"
  - `link` — string (uri): Solo Facebook, post de enlace.
  - `settings` — object: Ajustes por red. Cambiarlos cambia el content_hash.
    - `instagram` — object
      - `share_to_feed` — boolean: Reels: también en el feed (default de Instagram: sí).
      - `cover_url` — string (uri): Reels: portada (JPEG https pública).
      - `thumb_offset_ms` — integer: Reels: fotograma para la portada. Se ignora si hay cover_url.
      - `collaborators` — array<string>: Feed y reels: usernames de colaboradores, sin @. Ellos deben aceptar.
    - `tiktok` — object: Obligatorio para legs de TikTok. Ver la sección TikTok arriba.
      - `privacy_level` — "PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY", requerido: Sin default: lo elige el usuario de `creator-info.privacy_level_options`.
      - `allow_comment` — boolean: Default false.
      - `allow_duet` — boolean: Solo video. Default false.
      - `allow_stitch` — boolean: Solo video. Default false.
      - `brand_content_toggle` — boolean: Partnership pagado ("Paid partnership"). No puede ir con SELF_ONLY.
      - `brand_organic_toggle` — boolean: Promoción de la propia marca ("Promotional content").
      - `is_aigc` — boolean: Etiqueta de contenido generado con IA.
      - `video_cover_timestamp_ms` — integer: Video: fotograma para la portada.
      - `title` — string: Fotos: encabezado. El caption va como description.
      - `photo_cover_index` — integer: Fotos: índice de la portada (default 0).
      - `auto_add_music` — boolean: Fotos: que TikTok agregue música.

Ejemplo:

```json
{
  "external_ref": "post-8f1c…",
  "profile_id": "…",
  "scheduled_at": "2026-09-20T15:00:00Z",
  "legs": [
    {
      "account_id": "…"
    },
    {
      "account_id": "…"
    }
  ],
  "payload": {
    "caption": "Conoce al equipo ✨",
    "media": [
      {
        "url": "https://media.tuapp.com/posts/equipo.jpg"
      }
    ]
  }
}
```

**Respuestas**

- `200` — Ya existía con el mismo contenido (o se re-agendó uno fallido/cancelado). · object
  - `job` — object
    - `id` — string (uuid), requerido
    - `external_ref` — string, requerido: Tu id del post (p. ej. content_posts.id).
    - `profile_id` — string (uuid), requerido
    - `status` — "scheduled" | "dispatching" | "published" | "partial" | "failed" | "canceled", requerido: `partial` = algunos legs publicaron y otros fallaron.
    - `scheduled_at` — string (date-time), requerido
    - `content_hash` — string, requerido: sha256 del contenido normalizado. Con `external_ref` hace único al job.
    - `payload` — object, requerido
      - `caption` — string
      - `media` — array<object>
        - `url` — string (uri)
        - `type` — "image" | "video"
      - `link` — string (uri)
      - `settings` — object
    - `legs` — array<object>, requerido
      - `id` — string (uuid), requerido
      - `account_id` — string (uuid), requerido
      - `platform` — "facebook" | "instagram" | "tiktok", requerido
      - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
      - `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
      - `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
      - `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
      - `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
      - `attempts` — integer, requerido
      - `error` — object (nullable), requerido
        - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
        - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
      - `started_at` — string (date-time, nullable)
      - `published_at` — string (date-time, nullable), requerido
      - `updated_at` — string (date-time)
    - `created_at` — string (date-time), requerido
    - `dispatched_at` — string (date-time, nullable)
    - `completed_at` — string (date-time, nullable)
    - `canceled_at` — string (date-time, nullable)
    - `updated_at` — string (date-time)
- `201` — Creado y programado. · object
  - `job` — object
    - `id` — string (uuid), requerido
    - `external_ref` — string, requerido: Tu id del post (p. ej. content_posts.id).
    - `profile_id` — string (uuid), requerido
    - `status` — "scheduled" | "dispatching" | "published" | "partial" | "failed" | "canceled", requerido: `partial` = algunos legs publicaron y otros fallaron.
    - `scheduled_at` — string (date-time), requerido
    - `content_hash` — string, requerido: sha256 del contenido normalizado. Con `external_ref` hace único al job.
    - `payload` — object, requerido
      - `caption` — string
      - `media` — array<object>
        - `url` — string (uri)
        - `type` — "image" | "video"
      - `link` — string (uri)
      - `settings` — object
    - `legs` — array<object>, requerido
      - `id` — string (uuid), requerido
      - `account_id` — string (uuid), requerido
      - `platform` — "facebook" | "instagram" | "tiktok", requerido
      - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
      - `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
      - `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
      - `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
      - `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
      - `attempts` — integer, requerido
      - `error` — object (nullable), requerido
        - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
        - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
      - `started_at` — string (date-time, nullable)
      - `published_at` — string (date-time, nullable), requerido
      - `updated_at` — string (date-time)
    - `created_at` — string (date-time), requerido
    - `dispatched_at` — string (date-time, nullable)
    - `completed_at` — string (date-time, nullable)
    - `canceled_at` — string (date-time, nullable)
    - `updated_at` — string (date-time)
- `400` — `invalid_body` / `account_not_in_profile` / `platform_not_supported`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — `not_found` — el profile o una cuenta no existe (o no es tuya). · → Error
- `409` — `account_disconnected`. · → Error

### GET /api/v1/jobs/{id} — Ver un job con sus legs

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del job

**Respuestas**

- `200` — OK · object
  - `job` — object
    - `id` — string (uuid), requerido
    - `external_ref` — string, requerido: Tu id del post (p. ej. content_posts.id).
    - `profile_id` — string (uuid), requerido
    - `status` — "scheduled" | "dispatching" | "published" | "partial" | "failed" | "canceled", requerido: `partial` = algunos legs publicaron y otros fallaron.
    - `scheduled_at` — string (date-time), requerido
    - `content_hash` — string, requerido: sha256 del contenido normalizado. Con `external_ref` hace único al job.
    - `payload` — object, requerido
      - `caption` — string
      - `media` — array<object>
        - `url` — string (uri)
        - `type` — "image" | "video"
      - `link` — string (uri)
      - `settings` — object
    - `legs` — array<object>, requerido
      - `id` — string (uuid), requerido
      - `account_id` — string (uuid), requerido
      - `platform` — "facebook" | "instagram" | "tiktok", requerido
      - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
      - `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
      - `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
      - `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
      - `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
      - `attempts` — integer, requerido
      - `error` — object (nullable), requerido
        - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
        - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
      - `started_at` — string (date-time, nullable)
      - `published_at` — string (date-time, nullable), requerido
      - `updated_at` — string (date-time)
    - `created_at` — string (date-time), requerido
    - `dispatched_at` — string (date-time, nullable)
    - `completed_at` — string (date-time, nullable)
    - `canceled_at` — string (date-time, nullable)
    - `updated_at` — string (date-time)
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

### PATCH /api/v1/jobs/{id} — Re-agendar

Solo `scheduled_at` y solo mientras el job siga `scheduled`. Cambiar el contenido es otro job: cancela éste y crea uno.

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del job

**Body** (application/json, requerido)

- `scheduled_at` — string (date-time), requerido

**Respuestas**

- `200` — Re-agendado. · object
  - `job` — object
    - `id` — string (uuid), requerido
    - `external_ref` — string, requerido: Tu id del post (p. ej. content_posts.id).
    - `profile_id` — string (uuid), requerido
    - `status` — "scheduled" | "dispatching" | "published" | "partial" | "failed" | "canceled", requerido: `partial` = algunos legs publicaron y otros fallaron.
    - `scheduled_at` — string (date-time), requerido
    - `content_hash` — string, requerido: sha256 del contenido normalizado. Con `external_ref` hace único al job.
    - `payload` — object, requerido
      - `caption` — string
      - `media` — array<object>
        - `url` — string (uri)
        - `type` — "image" | "video"
      - `link` — string (uri)
      - `settings` — object
    - `legs` — array<object>, requerido
      - `id` — string (uuid), requerido
      - `account_id` — string (uuid), requerido
      - `platform` — "facebook" | "instagram" | "tiktok", requerido
      - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
      - `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
      - `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
      - `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
      - `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
      - `attempts` — integer, requerido
      - `error` — object (nullable), requerido
        - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
        - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
      - `started_at` — string (date-time, nullable)
      - `published_at` — string (date-time, nullable), requerido
      - `updated_at` — string (date-time)
    - `created_at` — string (date-time), requerido
    - `dispatched_at` — string (date-time, nullable)
    - `completed_at` — string (date-time, nullable)
    - `canceled_at` — string (date-time, nullable)
    - `updated_at` — string (date-time)
- `400` — `invalid_body`. · → Error
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error
- `409` — `job_not_reschedulable` — ya se está publicando o ya terminó. · → Error

### DELETE /api/v1/jobs/{id} — Cancelar (con confirmación)

**Parámetros**

- `id` (path, requerido) — string (uuid): Id del job

**Respuestas**

- `200` — Siempre dice qué pasó. · object
  - `result` — "confirmed" | "in_flight" | "already_published", requerido: `confirmed` = no va a salir. `in_flight` = ya se está publicando, no se puede detener. `already_published` = ya salió.
  - `job` — object, requerido
    - `id` — string (uuid), requerido
    - `external_ref` — string, requerido: Tu id del post (p. ej. content_posts.id).
    - `profile_id` — string (uuid), requerido
    - `status` — "scheduled" | "dispatching" | "published" | "partial" | "failed" | "canceled", requerido: `partial` = algunos legs publicaron y otros fallaron.
    - `scheduled_at` — string (date-time), requerido
    - `content_hash` — string, requerido: sha256 del contenido normalizado. Con `external_ref` hace único al job.
    - `payload` — object, requerido
      - `caption` — string
      - `media` — array<object>
        - `url` — string (uri)
        - `type` — "image" | "video"
      - `link` — string (uri)
      - `settings` — object
    - `legs` — array<object>, requerido
      - `id` — string (uuid), requerido
      - `account_id` — string (uuid), requerido
      - `platform` — "facebook" | "instagram" | "tiktok", requerido
      - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
      - `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
      - `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
      - `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
      - `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
      - `attempts` — integer, requerido
      - `error` — object (nullable), requerido
        - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
        - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
      - `started_at` — string (date-time, nullable)
      - `published_at` — string (date-time, nullable), requerido
      - `updated_at` — string (date-time)
    - `created_at` — string (date-time), requerido
    - `dispatched_at` — string (date-time, nullable)
    - `completed_at` — string (date-time, nullable)
    - `canceled_at` — string (date-time, nullable)
    - `updated_at` — string (date-time)
- `401` — API key ausente, inválida o revocada. · → Error
- `404` — No existe, o no es de tu tenant. No se distingue. · → Error

## Esquemas

### Error

- `error` — string, requerido: Código estable, para ramificar en código. Nunca cambia de texto.
- `detail` — any: Contexto opcional para humanos o para depurar. Puede cambiar.

### Profile

- `id` — string (uuid), requerido
- `name` — string, requerido
- `external_ref` — string (nullable), requerido: Tu identificador de esta marca (p. ej. el id de tu workspace). Hace idempotente la creación.
- `created_at` — string (date-time), requerido

### Account

- `id` — string (uuid), requerido
- `platform` — "facebook" | "instagram" | "tiktok", requerido
- `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
- `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
- `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
- `username` — string (nullable), requerido
- `display_name` — string (nullable), requerido
- `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
- `connected` — boolean, requerido
- `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
- `disconnected_at` — string (date-time, nullable), requerido
- `created_at` — string (date-time), requerido

### Session

- `id` — string (uuid), requerido
- `platform` — "facebook" | "instagram" | "tiktok", requerido
- `profile_id` — string (uuid, nullable), requerido
- `status` — "pending" | "completed" | "expired" | "failed", requerido
- `error` — string (nullable), requerido: Solo con `failed`: `state_vencido`, `sin_paginas`, `finalize`, o `meta:<error>:<reason>` / `instagram:…` / `tiktok:…` tal como lo devolvió el proveedor.
- `return_url` — string (uri), requerido
- `accounts` — array<object>, requerido: Las cuentas que resultaron de esta sesión (vacío hasta que se complete).
  - `id` — string (uuid), requerido
  - `platform` — "facebook" | "instagram" | "tiktok", requerido
  - `profile_id` — string (uuid, nullable), requerido: `null` = sin asignar (modo agency). Asígnala con PATCH.
  - `grant_id` — string (uuid), requerido: La autorización de OAuth de la que cuelga. Varias cuentas pueden compartir un grant.
  - `platform_account_id` — string, requerido: El id en la plataforma: page id, IG user id, TikTok open_id.
  - `username` — string (nullable), requerido
  - `display_name` — string (nullable), requerido
  - `avatar_url` — string (nullable), requerido: Foto de perfil tal como la dio el proveedor al conectar. Es una URL de su CDN y **puede caducar**: descárgala y guarda tu copia si la vas a mostrar después.
  - `connected` — boolean, requerido
  - `token_health` — "valid" | "expiring" | "invalid", requerido: Lo último que se sabe. Para un chequeo vivo usa /health.
  - `disconnected_at` — string (date-time, nullable), requerido
  - `created_at` — string (date-time), requerido
- `expires_at` — string (date-time), requerido
- `created_at` — string (date-time), requerido
- `completed_at` — string (date-time, nullable), requerido

### Webhook

- `id` — string (uuid), requerido
- `url` — string (uri), requerido
- `events` — array<string>, requerido: `null` = todos.
- `active` — boolean, requerido
- `consecutive_failures` — integer, requerido
- `created_at` — string (date-time), requerido

### Insights

- `account_id` — string (uuid), requerido
- `platform` — "facebook" | "instagram" | "tiktok", requerido
- `period` — object, requerido: Días UTC, inclusivos.
  - `from` — string (date)
  - `to` — string (date)
- `source` — "graph.facebook.com" | "graph.instagram.com" | "open.tiktokapis.com", requerido: Instagram vía la página lee de graph.facebook.com; por Instagram Login, de graph.instagram.com.
- `followers` — integer (nullable), requerido: Seguidores HOY (dato de vida, no del periodo).
- `totals` — object, requerido: Totales del periodo. `null` = el proveedor no lo dio. **instagram**: `reach`, `views` (antes impressions), `profile_views`, `accounts_engaged`, `interactions`, `likes`, `comments`, `shares`, `saves`, `website_clicks`. **facebook** (página): `post_engagements`, `page_views`, `follows` (nuevos), `video_views`, `reactions`. **tiktok** (de vida, no del periodo): `likes`, `videos`, `following`.
- `daily` — array<object>, requerido: instagram: `reach`, `new_followers`. facebook: `post_engagements`, `page_views`, `follows`, `video_views`, `reactions`. tiktok: vacío.
  - `date` — string (date), requerido: Día según el corte del proveedor (Meta: medianoche del Pacífico).
  - `<otra clave>` — number
- `fetched_at` — string (date-time), requerido

### Post

- `id` — string, requerido: Id en la plataforma: media id de IG, post id de FB (`<page>_<post>`), video id de TikTok.
- `platform` — "facebook" | "instagram" | "tiktok", requerido
- `type` — "image" | "carousel" | "video" | "reel" | "text" | "link" | "unknown", requerido
- `permalink` — string (nullable), requerido
- `caption` — string (nullable), requerido
- `published_at` — string (date-time), requerido
- `thumbnail_url` — string (nullable), requerido: Imagen de vista previa (para video/reel, su portada). Es una URL del CDN del proveedor y **caduca en horas**: si la vas a mostrar después, descárgala al recibirla y guarda tu copia (ver *Contenido*).
- `media_url` — string (nullable), requerido: El archivo tal cual (imagen o video). Solo Instagram; misma caducidad.
- `metrics` — object, requerido: `null` = el proveedor no lo dio. **instagram**: `likes`, `comments`, `shares`, `saves`, `reach`, `views`, `interactions`. **facebook**: `reactions`, `likes`, `comments`, `shares`, `clicks`, `video_views`. Un post de página no tiene `reach` ni `views`: Meta las retiró de la API (ver *Métricas*). **tiktok**: `views`, `likes`, `comments`, `shares`, `duration_s`.

### Job

- `id` — string (uuid), requerido
- `external_ref` — string, requerido: Tu id del post (p. ej. content_posts.id).
- `profile_id` — string (uuid), requerido
- `status` — "scheduled" | "dispatching" | "published" | "partial" | "failed" | "canceled", requerido: `partial` = algunos legs publicaron y otros fallaron.
- `scheduled_at` — string (date-time), requerido
- `content_hash` — string, requerido: sha256 del contenido normalizado. Con `external_ref` hace único al job.
- `payload` — object, requerido
  - `caption` — string
  - `media` — array<object>
    - `url` — string (uri)
    - `type` — "image" | "video"
  - `link` — string (uri)
  - `settings` — object
- `legs` — array<object>, requerido
  - `id` — string (uuid), requerido
  - `account_id` — string (uuid), requerido
  - `platform` — "facebook" | "instagram" | "tiktok", requerido
  - `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
  - `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
  - `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
  - `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
  - `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
  - `attempts` — integer, requerido
  - `error` — object (nullable), requerido
    - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
    - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
  - `started_at` — string (date-time, nullable)
  - `published_at` — string (date-time, nullable), requerido
  - `updated_at` — string (date-time)
- `created_at` — string (date-time), requerido
- `dispatched_at` — string (date-time, nullable)
- `completed_at` — string (date-time, nullable)
- `canceled_at` — string (date-time, nullable)
- `updated_at` — string (date-time)

### JobLeg

- `id` — string (uuid), requerido
- `account_id` — string (uuid), requerido
- `platform` — "facebook" | "instagram" | "tiktok", requerido
- `post_type` — "text" | "image" | "carousel" | "video" | "reel" | "story", requerido
- `status` — "pending" | "in_flight" | "published" | "failed" | "canceled", requerido
- `external_post_id` — string (nullable), requerido: Id del post en la red (media id de IG, post id de FB). En stories, la última publicada.
- `external_post_ids` — array<string>: Solo stories: una por media, en el orden del post.
- `permalink` — string (nullable), requerido: URL pública del post. Puede llegar `null` si la red aún no la expone (video recién subido).
- `attempts` — integer, requerido
- `error` — object (nullable), requerido
  - `code` — integer: Código numérico del proveedor (Meta) o negativo si es del servicio. Ramifica por esto.
  - `message` — string: Texto del proveedor, puede venir en cualquier idioma.
- `started_at` — string (date-time, nullable)
- `published_at` — string (date-time, nullable), requerido
- `updated_at` — string (date-time)

