VitrinaAPI

Integraciones · Meta Ads

La conexión de la cuenta publicitaria de Meta: iniciarla, resolver la selección de cuenta cuando hay más de una, desconectarla y leer los anuncios de click-to-WhatsApp del workspace.

Beta
Puede cambiar en cualquier momento, con una entrada en el changelog y aviso a quienes la llamaron recientemente — ver versionado.

Descarga la proyección completa de la API pública: openapi.json.

MétodoRutaQué hace
GET/integrations/meta-adsRead the workspace’s Meta Ads connection
GET/integrations/meta-ads/adsThe tenant's click-to-WhatsApp ads, with connect/greeting checks
POST/integrations/meta-ads/connectStart a Meta Ads connect and get the consent URL
POST/integrations/meta-ads/disconnectDisconnect the workspace’s Meta Ads ad account
POST/integrations/meta-ads/finalizeFinalize a Meta Ads pending selection with a chosen candidate
POST/integrations/meta-ads/returnSettle a Meta Ads connect after the dealer comes back

GET /integrations/meta-ads

Read the workspace’s Meta Ads connection

The tenant’s single Meta Ads row, or null when it has never started a connect. A placeholder object with status: "disconnected" is deliberately NOT returned in its place: "never connected" and "was connected and is not any more" are different states and the card says different things about them.

handoff is populated only while status is pending and candidates only while it is pending_selection — a spent consent URL would render a button leading to a dead page.

Admin-scoped (integrations:read), like the rest of Integraciones — that scope lives only in the admin bundle, so a supervisor or agent gets 403 here and never sees the card. The two writes need integrations:write on top.

curl https://api.vitrinadev.com/api/v1/integrations/meta-ads \
  -H "Authorization: Bearer $VITRINA_KEY"

Ejemplo de respuesta (200)

{
  "data": null
}

Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /integrations/meta-ads/ads

The tenant's click-to-WhatsApp ads, with connect/greeting checks

Proxies Atribu’s partner click-to-WhatsApp ads list with the tenant’s WhatsApp partner token — a different credential than the connect rail above’s delegated key — and derives two facts per ad: checks.destination_connected (the ad’s WhatsApp number, compared as E.164, is one of the tenant’s connected WhatsApp messaging accounts) and checks.greeting_configured (a "Saludo automático" — text or tappable ice breakers — is actually set).

An admin-scoped read (integrations:read is admin-only in the scope catalog, not "any member"), free for every tenant (no Add-on gate).

data.state is not_connected in TWO cases, told apart by an optional reason:

  • no reason — the tenant has never connected a Meta Ads account (the integration row is missing or not connected).
  • reason: "whatsapp_not_connected" — Meta Ads may be connected, but the tenant holds no whatsapp_cloud account carrying an Atribu partner token, so there is no scope to list ads under.

cursor is Atribu’s own opaque keyset cursor (its pagination.cursor, a Meta ad id ascending), passed straight through as after. next_cursor is null once Atribu reports has_next: false. last_synced_at is the max over the returned PAGE only, not a tenant-wide sync timestamp.

502 UPSTREAM_ERROR on an Atribu 5xx, timeout, or a response shape this route does not recognise. 502 partner_token_invalid when Atribu answers 401 on the WhatsApp partner token — that token is never auto-rotated; the dealer must reconnect WhatsApp.

ParámetroEnTipoObligatorioRestricciones
cursorquerystringnomín. 1
limitqueryintegerno≥ 1, ≤ 100, por defecto 25
curl https://api.vitrinadev.com/api/v1/integrations/meta-ads/ads \
  -H "Authorization: Bearer $VITRINA_KEY"

Ejemplo de respuesta (200)

{
  "data": {
    "state": "connected",
    "items": [
      {
        "ad_id": "1234567890123",
        "ad_name": "Test drive fin de semana",
        "ad_status": "ACTIVE",
        "campaign": {
          "id": "6543210987654",
          "name": "Leads · WhatsApp"
        },
        "ad_set": {
          "id": "6543210987655",
          "name": "Región Metropolitana"
        },
        "destination": {
          "type": "whatsapp",
          "whatsapp_phone_number": "+56912345678",
          "page_id": "998877665544"
        },
        "cta_type": "WHATSAPP_MESSAGE",
        "greeting": {
          "text": "¡Hola! Cuéntanos qué auto buscas.",
          "autofill_message": "Quiero agendar un test drive",
          "ice_breakers": [
            "Ver stock disponible",
            "Agendar test drive"
          ],
          "landing_screen_type": "welcome_message"
        },
        "greeting_status": "present",
        "checks": {
          "destination_connected": true,
          "greeting_configured": true
        },
        "last_synced_at": "2026-09-23T14:00:00.000Z"
      }
    ],
    "next_cursor": null,
    "last_synced_at": "2026-09-23T14:00:00.000Z"
  }
}

Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /integrations/meta-ads/connect

Start a Meta Ads connect and get the consent URL

Mints Atribu’s connect hand-off for meta_ads and answers the session-less consent URL to redirect the WHOLE PAGE to. It is not an iframe target: Meta’s own consent dialog renders inside it.

return_to must be an absolute URL on the app’s own origin. The backend appends meta_ads=return and a short opaque state, preserving any query the URL already carried, and passes the result to Atribu as return_url. An origin that is not the app’s is a 400 here rather than a silent substitution — Atribu itself only checks the origin when the human CLICKS, where it surfaces as a failed hand-off (result.reason: origin_not_allowed) after the dealer has already walked through a consent screen for nothing.

A second click reuses the live hand-off. Atribu’s URL is single-use and 45-minute-lived, and minting twice yields two hand-offs rather than an error, so a dealer who clicks, gets distracted and clicks again gets the SAME url and handoff_id back until it expires.

Before anything is minted, the tenant’s attribution profile and its delegated key are ensured (ADR 0099). This rail never uses the global ATRIBU_API_KEY.

curl -X POST https://api.vitrinadev.com/api/v1/integrations/meta-ads/connect \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "return_to": "https://app.vitrinadev.com/settings/integraciones?meta_ads=return"
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "url": "https://consent.atribu.app/connect/meta-ads/ho_4d8f2a91c6b3",
    "handoff_id": "ho_4d8f2a91c6b3",
    "expires_at": "2026-09-23T15:45:00.000Z"
  }
}

Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /integrations/meta-ads/disconnect

Disconnect the workspace’s Meta Ads ad account

Calls Atribu’s connections/\{id\}/disconnect operation on the delegated key and settles the row disconnected, keeping its history: account_label and connected_at stay, and config.last_account remembers which account this was so the card can still name it. Reconnecting afterwards is a plain new /connect — it mints a fresh hand-off. A tenant must always be able to walk away from a connection it made.

A row with nothing at Atribu to revoke — pending / pending_selection with no connection — is cancelled LOCALLY: no Atribu call, the row moves straight to disconnected, and the pending hand-off simply expires on its own (Atribu has no endpoint to cancel one early). A row already disconnected answers 200 unchanged (idempotent).

409 sync_in_flight when Atribu is mid-sync on the connection — the row is left exactly as it was; the whole recovery is trying again in a few minutes. 502 atribu_refused when Atribu’s own 403 says the delegated key is no longer good for this connection — also left unchanged. A 404 from Atribu (the connection is already gone there) is treated as success: the state this call exists to ensure already holds.

curl -X POST https://api.vitrinadev.com/api/v1/integrations/meta-ads/disconnect \
  -H "Authorization: Bearer $VITRINA_KEY"

Ejemplo de respuesta (200)

{
  "data": {
    "id": "22222222-2222-4222-8222-222222222222",
    "provider": "meta_ads",
    "status": "disconnected",
    "account_label": "Cuenta Meta Ads",
    "connected_at": "2026-09-20T12:00:00.000Z",
    "account": null,
    "error": null,
    "handoff": null,
    "candidates": null,
    "updated_at": "2026-09-23T15:10:00.000Z"
  }
}

Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /integrations/meta-ads/finalize

Finalize a Meta Ads pending selection with a chosen candidate

Only meaningful while status is pending_selection. Calls Atribu’s finalize on the parked provider token with the chosen candidate_id — a MetaAdsCandidate.candidate_id from the SAME row’s candidates, sent back verbatim — reads the resulting connection back (Atribu’s connections?channel=meta_ads operation) and runs it through the same settle path /return uses, so the row reads identically whichever produced it: connected, with account populated.

Retrying the same candidate_id is safe and answers 200 with the current row — Atribu’s already_finalized: true on its own idempotent replay, reapplied through the same write path a fresh finalize uses.

A DIFFERENT candidate_id after Atribu’s parked token already finalized a choice is 409 start_again (the row moves to error, reason: selection_conflict, retryable) — switching accounts needs a fresh connect, there is no token left to retarget.

The picker’s 60-minute parked token already gone (elapsed, or never existed for this profile) is 410 reconnect (the row moves to error, reason: selection_expired, retryable).

The row is not pending_selection — nothing started, already connected, or already in error — is 409 handoff_mismatch, checked before Atribu is ever called.

curl -X POST https://api.vitrinadev.com/api/v1/integrations/meta-ads/finalize \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "candidate_id": "cnd_7b1f0a4e9d2c"
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "id": "22222222-2222-4222-8222-222222222222",
    "provider": "meta_ads",
    "status": "connected",
    "account_label": "Cuenta Meta Ads",
    "connected_at": "2026-09-23T15:04:00.000Z",
    "account": {
      "external_account_id": "act_1234567890",
      "name": "Automotora Ejemplo",
      "currency": "CLP",
      "timezone": "America/Santiago",
      "delivers_spend": true,
      "atribu_connection_id": "conn_9f3a2d7c8b1e"
    },
    "error": null,
    "handoff": null,
    "candidates": null,
    "updated_at": "2026-09-23T15:04:00.000Z"
  }
}

Responde: 200 · 400 · 401 · 403 · 404 · 409 · 410 · 429

POST /integrations/meta-ads/return

Settle a Meta Ads connect after the dealer comes back

Reads the hand-off ONCE and settles the row:

  • completed with a connection id → the connection is read back from Atribu and the row becomes connected, carrying the ad account’s name, id, currency, timezone and whether the platform has ever delivered spend.
  • completed with a pending selection → the row becomes pending_selection and carries the candidates the picker renders. A consent that resolved to exactly one account never reaches this branch — Atribu finalizes it in its own callback, which is why there is no auto-finalize step on this side.
  • expired / cancelled / failed → the row becomes error with a retryable reason of handoff_<status>. The recovery is identical for all three: mint a fresh hand-off.
  • still pending → nothing is settled and the row comes back unchanged.

Neither field is required. The hand-off id only exists in the RESPONSE to the mint while return_url is an argument to that same mint, so the return URL structurally cannot carry it — it carries meta_ads=return and the opaque state instead. Send handoff_id if you kept it, state if you read it off the URL, or an empty body, in which case the backend settles the row’s own pending hand-off (unambiguous: a tenant has at most one).

Safe to repeat — a settled hand-off stays readable at Atribu, so a re-post on a refresh re-derives the same state.

409 handoff_mismatch when the id or state presented does not name the hand-off this tenant’s row is waiting on, and for a tenant with no connect in flight at all (the two are deliberately the same answer).

curl -X POST https://api.vitrinadev.com/api/v1/integrations/meta-ads/return \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "state": "9f3a2d7c8b1e4a2f"
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "id": "22222222-2222-4222-8222-222222222222",
    "provider": "meta_ads",
    "status": "connected",
    "account_label": "Cuenta Meta Ads",
    "connected_at": "2026-09-23T15:04:00.000Z",
    "account": {
      "external_account_id": "act_1234567890",
      "name": "Automotora Ejemplo",
      "currency": "CLP",
      "timezone": "America/Santiago",
      "delivers_spend": true,
      "atribu_connection_id": "conn_9f3a2d7c8b1e"
    },
    "error": null,
    "handoff": null,
    "candidates": null,
    "updated_at": "2026-09-23T15:04:00.000Z"
  }
}

Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429

En esta página