Integrations · Meta Ads
The Meta Ads account connection: start it, resolve the account selection when there is more than one, disconnect it, and read the workspace's click-to-WhatsApp ads.
Download the full API pública projection: openapi.json.
| Method | Path | What it does |
|---|---|---|
GET | /integrations/meta-ads | Read the workspace’s Meta Ads connection |
GET | /integrations/meta-ads/ads | The tenant's click-to-WhatsApp ads, with connect/greeting checks |
POST | /integrations/meta-ads/connect | Start a Meta Ads connect and get the consent URL |
POST | /integrations/meta-ads/disconnect | Disconnect the workspace’s Meta Ads ad account |
POST | /integrations/meta-ads/finalize | Finalize a Meta Ads pending selection with a chosen candidate |
POST | /integrations/meta-ads/return | Settle 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"Example response (200)
{
"data": null
}Answers: 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 notconnected). reason: "whatsapp_not_connected"— Meta Ads may be connected, but the tenant holds nowhatsapp_cloudaccount 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
cursor | query | string | no | mín. 1 |
limit | query | integer | no | ≥ 1, ≤ 100, por defecto 25 |
curl https://api.vitrinadev.com/api/v1/integrations/meta-ads/ads \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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"
}
}Answers: 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"
}'Example response (200)
{
"data": {
"url": "https://consent.atribu.app/connect/meta-ads/ho_4d8f2a91c6b3",
"handoff_id": "ho_4d8f2a91c6b3",
"expires_at": "2026-09-23T15:45:00.000Z"
}
}Answers: 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"Example response (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"
}
}Answers: 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"
}'Example response (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"
}
}Answers: 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:
completedwith a connection id → the connection is read back from Atribu and the row becomesconnected, carrying the ad account’s name, id, currency, timezone and whether the platform has ever delivered spend.completedwith a pending selection → the row becomespending_selectionand 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 becomeserrorwith a retryablereasonofhandoff_<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"
}'Example response (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"
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
Help centers
The customer-facing documentation portal: sections, multi-locale articles and their sync into the knowledge base.
Knowledge base — sources and indexed text
The agent's hand-authored sources — a problem, its root cause and the solution steps, written by hand instead of uploaded — and a library file's indexed text: literally what the agent searches.