VitrinaAPI

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.

Beta
This may change at any time, with a changelog entry and a notice to recent callers — see versioning.

Download the full API pública projection: openapi.json.

MethodPathWhat it does
GET/integrations/connectionsList the workspace’s provider connections (CRM, payments)
GET/integrations/connections/{provider}Read one provider connection card
POST/integrations/connections/{provider}/connectStart (or resume) a provider connect
POST/integrations/connections/{provider}/disconnectDisconnect a provider
POST/integrations/connections/{provider}/finalizeChoose the account to connect from an open picker
POST/integrations/connections/{provider}/returnSettle a provider connect after the admin comes back
GET/integrations/connections/{provider}/stagesRead a CRM’s pipeline stages and what each one counts as
PATCH/integrations/connections/{provider}/stages/{stageId}Set what one CRM stage counts as
POST/integrations/connections/{provider}/stages/syncRe-read the CRM’s pipelines
POST/integrations/connections/{provider}/syncSync a connected store’s catalog now
DELETE/integrations/external-stock-sourceRemove the workspace’s external stock source
GET/integrations/external-stock-sourceRead the workspace’s external stock source
PUT/integrations/external-stock-sourceSave the workspace’s external stock source
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
GET/integrations/meta-ads/pagesThe Facebook Pages the Meta account can see, and which are tracked
PUT/integrations/meta-ads/pagesChoose the Facebook Pages to track
POST/integrations/meta-ads/returnSettle a Meta Ads connect after the dealer comes back
GET/integrations/woocommerceRead the workspace’s WooCommerce connection
POST/integrations/woocommerce/checkCheck the WooCommerce connection again
POST/integrations/woocommerce/connectStart connecting a WooCommerce store
POST/integrations/woocommerce/disconnectDisconnect the WooCommerce store
POST/integrations/woocommerce/syncSincronizar ahora — reconcile the store’s catalog

GET /integrations/connections

List the workspace’s provider connections (CRM, payments)

One card per connectable provider — GoHighLevel, Mercado Pago, Shopify — ALWAYS every provider, never-connected ones included (state: "not_connected"), so an integrations screen renders from this one read. Each card is exactly what GET /integrations/connections/\{provider\} returns.

Requires integrations:read (owner and admin).

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

Example response (200)

{
  "data": [
    {
      "provider": "gohighlevel",
      "display_name": "GoHighLevel",
      "category": "crm",
      "description": "Trae los formularios y las oportunidades de tu CRM para seguir cada lead hasta la venta.",
      "last_account": null,
      "last_attempt": null,
      "disconnected_at": null,
      "connect_input": "none",
      "connect_notice": null,
      "catalog": null,
      "addon_active": true,
      "state": "connecting",
      "state_label": "Elige la subcuenta",
      "account": null,
      "reason": null,
      "next_step": {
        "action": "choose_account",
        "label": "Elegir subcuenta"
      },
      "attempt": {
        "step": "choose_account",
        "handoff": {
          "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
          "expires_at": "2026-09-27T15:45:00.000Z"
        },
        "candidates": [
          {
            "external_id": "loc_8XkP2qR5tV",
            "name": "Agencia Ejemplo — Ventas"
          },
          {
            "external_id": "loc_3MnB7wQ1yZ",
            "name": "Agencia Ejemplo — Soporte"
          }
        ],
        "selection_expires_at": "2026-09-27T16:00:00.000Z",
        "prompt": "Elige la subcuenta de GoHighLevel que quieres conectar."
      },
      "connected_at": null,
      "last_synced_at": null,
      "updated_at": "2026-09-27T15:02:00.000Z"
    },
    {
      "provider": "mercadopago",
      "display_name": "Mercado Pago",
      "category": "payments",
      "description": "Registra los pagos que recibes para saber cuántas ventas trae cada campaña.",
      "state": "not_connected",
      "state_label": "Sin conectar",
      "account": null,
      "last_account": null,
      "reason": null,
      "last_attempt": null,
      "next_step": {
        "action": "connect",
        "label": "Conectar"
      },
      "attempt": null,
      "connected_at": null,
      "disconnected_at": null,
      "last_synced_at": null,
      "updated_at": null,
      "connect_input": "none",
      "connect_notice": null,
      "catalog": null,
      "addon_active": true
    }
  ]
}

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

GET /integrations/connections/{provider}

Read one provider connection card

Everything the card renders, in one read: the state and its label, the connected account, the reason with a ready-to-render sentence, how the latest re-authorization ended (last_attempt), the next step with its button label, when it connected / stopped / last synced, and — only while a connect is in flight — the attempt. Never null: a workspace that never connected gets state: "not_connected".

A connection whose last sync failed stays connected with reason.code: "sync_error" (label «Problema de sincronización») and a reconnect next step; only a grant the provider no longer honours is needs_reconnect.

While an account picker is open the candidates are re-read from the provider when older than five minutes; every other state is a database read only.

Requires integrations:read (owner and admin).

ParameterInTypeRequiredConstraints
providerpathgohighlevel \mercadopago \shopify
curl https://api.vitrinadev.com/api/v1/integrations/connections/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "provider": "gohighlevel",
    "display_name": "GoHighLevel",
    "category": "crm",
    "description": "Trae los formularios y las oportunidades de tu CRM para seguir cada lead hasta la venta.",
    "last_account": null,
    "last_attempt": null,
    "disconnected_at": null,
    "connect_input": "none",
    "connect_notice": null,
    "catalog": null,
    "addon_active": true,
    "state": "connected",
    "state_label": "Conectado",
    "account": {
      "external_id": "loc_8XkP2qR5tV",
      "name": "Agencia Ejemplo"
    },
    "reason": null,
    "next_step": {
      "action": "none",
      "label": null
    },
    "attempt": null,
    "connected_at": "2026-09-27T15:04:00.000Z",
    "last_synced_at": "2026-09-27T15:20:00.000Z",
    "updated_at": "2026-09-27T15:20:00.000Z"
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 429

POST /integrations/connections/{provider}/connect

Start (or resume) a provider connect

Starts the provider’s consent and answers the URL to send the WHOLE page to — straight to the provider’s own consent screen, which names this app. Every ending (connected, several accounts to choose from, cancelled, refused, expired) comes back to return_to with connection_return=&lt;provider&gt;, connection_state, and the provider’s status, reason and handoff_id; pass all of them to POST …/return, then strip them from the address bar.

return_to must be an absolute URL on the app’s own origin (400 otherwise).

Idempotent. A second click while a consent is live for the same return_to answers the SAME url and handoff_id. It works from every state: a first connect, a retry, a reconnect (needs_reconnect), a re-authorization of a connected account. Only a first connect moves the card to connecting; a reconnect keeps its state until the new consent lands.

Shopify (connect_input: "shop"): send shop, the store’s address. 400 with error.details.reason: "invalid_shop" (and a Spanish message naming the fix) for a missing one or a custom storefront domain; 409 with error.details.reason: "other_store_connected" while a DIFFERENT store is connected (disconnect it first). Shopify’s consent screen names the connectivity partner’s app: show the card’s connect_notice before sending the page there. Once connected, the store’s catalog is imported and kept live (see catalog on the card and POST …/sync). A store needs no add-on.

Requires integrations:write (owner and admin).

ParameterInTypeRequiredConstraints
providerpathgohighlevel \mercadopago \shopify
curl -X POST https://api.vitrinadev.com/api/v1/integrations/connections/<id>/connect \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "return_to": "https://app.vitrinadev.com/w/agencia/settings/integrations"
  }'

Example response (200)

{
  "data": {
    "url": "https://connect.example.com/gohighlevel/start?h=4d8f2a91c6b3",
    "handoff_id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "expires_at": "2026-09-27T15:45:00.000Z",
    "connection": {
      "provider": "gohighlevel",
      "display_name": "GoHighLevel",
      "category": "crm",
      "description": "Trae los formularios y las oportunidades de tu CRM para seguir cada lead hasta la venta.",
      "last_account": null,
      "last_attempt": null,
      "disconnected_at": null,
      "connect_input": "none",
      "connect_notice": null,
      "catalog": null,
      "addon_active": true,
      "state": "connecting",
      "state_label": "Conectando",
      "account": null,
      "reason": null,
      "next_step": {
        "action": "resume",
        "label": "Continuar en GoHighLevel"
      },
      "attempt": {
        "step": "consent",
        "handoff": {
          "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
          "expires_at": "2026-09-27T15:45:00.000Z"
        },
        "candidates": null,
        "selection_expires_at": null,
        "prompt": "Termina la autorización en GoHighLevel para conectar."
      },
      "connected_at": null,
      "last_synced_at": null,
      "updated_at": "2026-09-27T15:00:00.000Z"
    }
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 429

POST /integrations/connections/{provider}/disconnect

Disconnect a provider

Disconnects the account at the provider and settles the card disconnected with reason.code: "disconnected_by_user", keeping last_account so the card can say what was disconnected. A connect in flight with nothing connected yet is cancelled locally. Idempotent: a card with nothing live answers as it stands.

409 sync_in_flight — the provider is mid-sync; nothing changed, try again in a few minutes. 502 disconnect_refused — the provider refused; nothing changed. 502 UPSTREAM_ERROR — the provider could not be reached; nothing changed.

Requires integrations:write.

ParameterInTypeRequiredConstraints
providerpathgohighlevel \mercadopago \shopify
curl -X POST https://api.vitrinadev.com/api/v1/integrations/connections/<id>/disconnect \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "provider": "gohighlevel",
    "display_name": "GoHighLevel",
    "category": "crm",
    "description": "Trae los formularios y las oportunidades de tu CRM para seguir cada lead hasta la venta.",
    "last_account": {
      "external_id": "loc_8XkP2qR5tV",
      "name": "Agencia Ejemplo"
    },
    "last_attempt": null,
    "disconnected_at": "2026-09-28T10:00:00.000Z",
    "connect_input": "none",
    "connect_notice": null,
    "catalog": null,
    "addon_active": true,
    "state": "disconnected",
    "state_label": "Desconectado",
    "account": null,
    "reason": {
      "code": "disconnected_by_user",
      "label": "Desconectaste GoHighLevel.",
      "retryable": true
    },
    "next_step": {
      "action": "connect",
      "label": "Conectar"
    },
    "attempt": null,
    "connected_at": null,
    "last_synced_at": "2026-09-27T15:20:00.000Z",
    "updated_at": "2026-09-28T10:00:00.000Z"
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 429

POST /integrations/connections/{provider}/finalize

Choose the account to connect from an open picker

Completes an open account picker (attempt.step: "choose_account") with one attempt.candidates[].external_id. Served only for providers whose consent can end in a choice (GoHighLevel sub-accounts); any other provider is 404. Idempotent: the same account again on a card it already connected answers the card.

  • 409 handoff_mismatch — handoff_id was sent and is not the consent this card is waiting on (a stale tab); refresh the card;
  • 409 not_selecting — no choice is owed (refresh the card);
  • 409 account_connected_elsewhere — that account lives on another Vitrina workspace; the picker STAYS open, choose another;
  • 409 selection_conflict — another account was already chosen on this consent; connect again to change it;
  • 410 selection_expired — the picker’s 60-minute window ended; connect again;
  • 422 candidate_unavailable — the account is no longer reachable; the next card read refreshes the list.

Requires integrations:write.

ParameterInTypeRequiredConstraints
providerpathgohighlevelyes
curl -X POST https://api.vitrinadev.com/api/v1/integrations/connections/<id>/finalize \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "loc_8XkP2qR5tV",
    "handoff_id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21"
  }'

Example response (200)

{
  "data": {
    "provider": "gohighlevel",
    "display_name": "GoHighLevel",
    "category": "crm",
    "description": "Trae los formularios y las oportunidades de tu CRM para seguir cada lead hasta la venta.",
    "last_account": null,
    "last_attempt": null,
    "disconnected_at": null,
    "connect_input": "none",
    "connect_notice": null,
    "catalog": null,
    "addon_active": true,
    "state": "connected",
    "state_label": "Conectado",
    "account": {
      "external_id": "loc_8XkP2qR5tV",
      "name": "Agencia Ejemplo"
    },
    "reason": null,
    "next_step": {
      "action": "none",
      "label": null
    },
    "attempt": null,
    "connected_at": "2026-09-27T15:04:00.000Z",
    "last_synced_at": "2026-09-27T15:20:00.000Z",
    "updated_at": "2026-09-27T15:20:00.000Z"
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 410 · 422 · 429

POST /integrations/connections/{provider}/return

Settle a provider connect after the admin comes back

Settles the connect on the provider’s own record of the consent — the status and reason from the return URL are hints only (a URL can be typed):

  • connected → state: "connected" with the account read back from the provider;
  • several accounts (GoHighLevel) → attempt.step: "choose_account" with attempt.candidates;
  • cancelled / refused / expired → the attempt ends with a SPECIFIC reason (user_cancelled, account_connected_elsewhere, no_candidates, provider_denied, …), never folded into one generic failure. On a card that was already connected the state is unchanged and the outcome is last_attempt;
  • not finished yet → the card comes back unchanged. If the return URL says the attempt failed with the retryable internal_error, the reason is recorded and the SAME consent link stays live for a retry; any other failure ends the attempt.

Every field is optional: a stripped query string still settles the workspace’s own pending consent (there is at most one). Idempotent: re-posting for a consent already settled answers the card as it stands. A consent finished in an OLDER tab after a newer connect answers the connected card.

409 handoff_mismatch when the handoff_id/state presented is neither this card’s consent nor its last one and nothing is connected. 502 when the provider could not be reached — nothing is settled; retry.

Requires integrations:write.

ParameterInTypeRequiredConstraints
providerpathgohighlevel \mercadopago \shopify
curl -X POST https://api.vitrinadev.com/api/v1/integrations/connections/<id>/return \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handoff_id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "state": "9f3a2d7c8b1e4a2f",
    "status": "success"
  }'

Example response (200)

{
  "data": {
    "provider": "gohighlevel",
    "display_name": "GoHighLevel",
    "category": "crm",
    "description": "Trae los formularios y las oportunidades de tu CRM para seguir cada lead hasta la venta.",
    "last_account": null,
    "last_attempt": null,
    "disconnected_at": null,
    "connect_input": "none",
    "connect_notice": null,
    "catalog": null,
    "addon_active": true,
    "state": "connected",
    "state_label": "Conectado",
    "account": {
      "external_id": "loc_8XkP2qR5tV",
      "name": "Agencia Ejemplo"
    },
    "reason": null,
    "next_step": {
      "action": "none",
      "label": null
    },
    "attempt": null,
    "connected_at": "2026-09-27T15:04:00.000Z",
    "last_synced_at": "2026-09-27T15:20:00.000Z",
    "updated_at": "2026-09-27T15:20:00.000Z"
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 429

GET /integrations/connections/{provider}/stages

Read a CRM’s pipeline stages and what each one counts as

Everything the «Etapas de tu CRM» sheet renders, in one read: every pipeline of the connected CRM in CRM order, each stage with the outcome it counts as (outcome, or null for nothing), how that came to be (mark: suggested / changed / default / unset), the suggestion its name points to (or why there is none), whether it can be edited now, and the outcome list for the picker in its order, ending in ignored («No contar»).

After the CRM connects, every stage nobody chose is set to its suggestion automatically; a stage someone chose is never touched, and a stage with no suggestion is left for a person (needs_choice, counted in summary.needs_choice).

Read-only and cheap: it reads the stored copy of the CRM’s pipelines, never the CRM live. status other than ready comes with status_label and a next_step. A CRM that is not connected answers status: "not_connected" with no pipelines.

provider: gohighlevel; any other provider is 404.

Requires integrations:read (owner and admin).

ParameterInTypeRequiredConstraints
providerpathgohighlevelyes
curl https://api.vitrinadev.com/api/v1/integrations/connections/<id>/stages \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "provider": "gohighlevel",
    "status": "ready",
    "status_label": null,
    "synced_at": "2026-09-27T10:00:00.000Z",
    "syncing": false,
    "can_write": true,
    "write_blocked": null,
    "next_step": {
      "action": "none",
      "label": null
    },
    "outcomes": [
      {
        "key": "lead_created",
        "label": "Lead nuevo"
      },
      {
        "key": "contacted",
        "label": "Contactado"
      },
      {
        "key": "follow_up",
        "label": "En seguimiento"
      },
      {
        "key": "appointment_booked",
        "label": "Cita agendada"
      },
      {
        "key": "no_show",
        "label": "No asistió"
      },
      {
        "key": "showed",
        "label": "Asistió"
      },
      {
        "key": "qualified",
        "label": "Calificado"
      },
      {
        "key": "disqualified",
        "label": "Descalificado"
      },
      {
        "key": "closed_lost",
        "label": "Venta perdida"
      },
      {
        "key": "closed_won",
        "label": "Venta ganada"
      },
      {
        "key": "ignored",
        "label": "No contar"
      }
    ],
    "pipelines": [
      {
        "external_id": "pipeline_1",
        "name": "Ventas",
        "account_name": null,
        "stages": [
          {
            "external_id": "stage_1",
            "name": "Nuevo lead",
            "position": 0,
            "outcome": "lead_created",
            "outcome_label": "Lead nuevo",
            "mark": "suggested",
            "suggested": {
              "outcome": "lead_created",
              "label": "Lead nuevo"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_2",
            "name": "Agendado",
            "position": 1,
            "outcome": "appointment_booked",
            "outcome_label": "Cita agendada",
            "mark": "suggested",
            "suggested": {
              "outcome": "appointment_booked",
              "label": "Cita agendada"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_3",
            "name": "Abono",
            "position": 2,
            "outcome": null,
            "outcome_label": null,
            "mark": "unset",
            "suggested": null,
            "no_suggestion_label": "El nombre de la etapa no indica un resultado. Elige uno.",
            "needs_choice": true,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_4",
            "name": "Venta cerrada",
            "position": 3,
            "outcome": "qualified",
            "outcome_label": "Calificado",
            "mark": "changed",
            "suggested": {
              "outcome": "closed_won",
              "label": "Venta ganada"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          }
        ]
      }
    ],
    "summary": {
      "pipelines": 1,
      "stages": 4,
      "suggested": 2,
      "changed": 1,
      "needs_choice": 1,
      "awaiting_sync": 0
    }
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 429

PATCH /integrations/connections/{provider}/stages/{stageId}

Set what one CRM stage counts as

Sets one stage’s outcome — an outcomes[].key, or ignored for «No contar». Applies to what is measured from now on. Idempotent: the outcome already in force is answered without a write. Answers the whole sheet, updated (the stage reads mark: "suggested" when the choice equals the suggestion, changed otherwise).

404 stage_not_found — the stage is no longer in the CRM. 409 stage_not_writable — a new stage still being read. 409 stages_write_blocked — the mapping cannot be edited now (error.details.reason, the same codes as write_blocked.code). 409 not_connected. 422 invalid_outcome. 402 without the Vitrina Ads add-on.

Requires integrations:write.

ParameterInTypeRequiredConstraints
providerpathgohighlevelyes
stageIdpathstringyes
curl -X PATCH https://api.vitrinadev.com/api/v1/integrations/connections/<id>/stages/<id> \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "outcome": "closed_won"
  }'

Example response (200)

{
  "data": {
    "provider": "gohighlevel",
    "status": "ready",
    "status_label": null,
    "synced_at": "2026-09-27T10:00:00.000Z",
    "syncing": false,
    "can_write": true,
    "write_blocked": null,
    "next_step": {
      "action": "none",
      "label": null
    },
    "outcomes": [
      {
        "key": "lead_created",
        "label": "Lead nuevo"
      },
      {
        "key": "contacted",
        "label": "Contactado"
      },
      {
        "key": "follow_up",
        "label": "En seguimiento"
      },
      {
        "key": "appointment_booked",
        "label": "Cita agendada"
      },
      {
        "key": "no_show",
        "label": "No asistió"
      },
      {
        "key": "showed",
        "label": "Asistió"
      },
      {
        "key": "qualified",
        "label": "Calificado"
      },
      {
        "key": "disqualified",
        "label": "Descalificado"
      },
      {
        "key": "closed_lost",
        "label": "Venta perdida"
      },
      {
        "key": "closed_won",
        "label": "Venta ganada"
      },
      {
        "key": "ignored",
        "label": "No contar"
      }
    ],
    "pipelines": [
      {
        "external_id": "pipeline_1",
        "name": "Ventas",
        "account_name": null,
        "stages": [
          {
            "external_id": "stage_1",
            "name": "Nuevo lead",
            "position": 0,
            "outcome": "lead_created",
            "outcome_label": "Lead nuevo",
            "mark": "suggested",
            "suggested": {
              "outcome": "lead_created",
              "label": "Lead nuevo"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_2",
            "name": "Agendado",
            "position": 1,
            "outcome": "appointment_booked",
            "outcome_label": "Cita agendada",
            "mark": "suggested",
            "suggested": {
              "outcome": "appointment_booked",
              "label": "Cita agendada"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_3",
            "name": "Abono",
            "position": 2,
            "outcome": null,
            "outcome_label": null,
            "mark": "unset",
            "suggested": null,
            "no_suggestion_label": "El nombre de la etapa no indica un resultado. Elige uno.",
            "needs_choice": true,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_4",
            "name": "Venta cerrada",
            "position": 3,
            "outcome": "qualified",
            "outcome_label": "Calificado",
            "mark": "changed",
            "suggested": {
              "outcome": "closed_won",
              "label": "Venta ganada"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          }
        ]
      }
    ],
    "summary": {
      "pipelines": 1,
      "stages": 4,
      "suggested": 2,
      "changed": 1,
      "needs_choice": 1,
      "awaiting_sync": 0
    }
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 429

POST /integrations/connections/{provider}/stages/sync

Re-read the CRM’s pipelines

Asks for a fresh read of the CRM’s pipelines — for a stage added in the CRM since the last read — and answers the stages as they stand (syncing: true). The read lands within minutes; new stages then get their suggestion applied automatically. Safe to repeat: a read already running is the same answer.

409 not_connected — the CRM is not connected.

Requires integrations:write.

ParameterInTypeRequiredConstraints
providerpathgohighlevelyes
curl -X POST https://api.vitrinadev.com/api/v1/integrations/connections/<id>/stages/sync \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "provider": "gohighlevel",
    "status": "ready",
    "status_label": null,
    "synced_at": "2026-09-27T10:00:00.000Z",
    "syncing": true,
    "can_write": true,
    "write_blocked": null,
    "next_step": {
      "action": "none",
      "label": null
    },
    "outcomes": [
      {
        "key": "lead_created",
        "label": "Lead nuevo"
      },
      {
        "key": "contacted",
        "label": "Contactado"
      },
      {
        "key": "follow_up",
        "label": "En seguimiento"
      },
      {
        "key": "appointment_booked",
        "label": "Cita agendada"
      },
      {
        "key": "no_show",
        "label": "No asistió"
      },
      {
        "key": "showed",
        "label": "Asistió"
      },
      {
        "key": "qualified",
        "label": "Calificado"
      },
      {
        "key": "disqualified",
        "label": "Descalificado"
      },
      {
        "key": "closed_lost",
        "label": "Venta perdida"
      },
      {
        "key": "closed_won",
        "label": "Venta ganada"
      },
      {
        "key": "ignored",
        "label": "No contar"
      }
    ],
    "pipelines": [
      {
        "external_id": "pipeline_1",
        "name": "Ventas",
        "account_name": null,
        "stages": [
          {
            "external_id": "stage_1",
            "name": "Nuevo lead",
            "position": 0,
            "outcome": "lead_created",
            "outcome_label": "Lead nuevo",
            "mark": "suggested",
            "suggested": {
              "outcome": "lead_created",
              "label": "Lead nuevo"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_2",
            "name": "Agendado",
            "position": 1,
            "outcome": "appointment_booked",
            "outcome_label": "Cita agendada",
            "mark": "suggested",
            "suggested": {
              "outcome": "appointment_booked",
              "label": "Cita agendada"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_3",
            "name": "Abono",
            "position": 2,
            "outcome": null,
            "outcome_label": null,
            "mark": "unset",
            "suggested": null,
            "no_suggestion_label": "El nombre de la etapa no indica un resultado. Elige uno.",
            "needs_choice": true,
            "writable": true,
            "not_writable_label": null
          },
          {
            "external_id": "stage_4",
            "name": "Venta cerrada",
            "position": 3,
            "outcome": "qualified",
            "outcome_label": "Calificado",
            "mark": "changed",
            "suggested": {
              "outcome": "closed_won",
              "label": "Venta ganada"
            },
            "no_suggestion_label": null,
            "needs_choice": false,
            "writable": true,
            "not_writable_label": null
          }
        ]
      }
    ],
    "summary": {
      "pipelines": 1,
      "stages": 4,
      "suggested": 2,
      "changed": 1,
      "needs_choice": 1,
      "awaiting_sync": 0
    }
  }
}

Answers: 200 · 400 · 401 · 402 · 403 · 404 · 409 · 429

POST /integrations/connections/{provider}/sync

Sync a connected store’s catalog now

«Sincronizar ahora» on a store card: enqueues a walk of the whole store — every product, variant and stock level, read live from the store — then switches off the products the store no longer lists. The same walk runs hourly and right after a connect; stock changes reach Vitrina within seconds on their own, without this call. The card’s catalog reports the outcome.

A walk that would switch off many products at once is held back (catalog.last_sync_status: "blocked"); force: true applies it, for a merchant who really emptied the store.

400 when the store is not connected; 404 for a provider that is not a store. Requires integrations:write.

ParameterInTypeRequiredConstraints
providerpathshopifyyes
curl -X POST https://api.vitrinadev.com/api/v1/integrations/connections/<id>/sync \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

Example response (202)

{
  "data": {
    "enqueued": true
  }
}

Answers: 202 · 400 · 401 · 403 · 404 · 409 · 429

DELETE /integrations/external-stock-source

Remove the workspace’s external stock source

Deletes the URL and the stored token. The «Stock faltante» alert then says «no pudimos verificar el stock». Requires integrations:write.

curl -X DELETE https://api.vitrinadev.com/api/v1/integrations/external-stock-source \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "removed": true
  }
}

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

GET /integrations/external-stock-source

Read the workspace’s external stock source

The back-office stock endpoint the «Stock faltante» alert asks live: its URL and a masked preview of its token (never the token). configured: false until a URL and a token are saved. Requires integrations:read.

curl https://api.vitrinadev.com/api/v1/integrations/external-stock-source \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "configured": true,
    "url": "https://backoffice.example.cl/api/vitrina/stock",
    "token_preview": "a1b2••••9z",
    "updated_at": "2026-10-28T15:00:00.000Z"
  }
}

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

PUT /integrations/external-stock-source

Save the workspace’s external stock source

Sets the endpoint (url, absolute https) and its token (token, sent as the x-vitrina-token header). Omit token to keep the stored one; it is required the first time. The endpoint must answer \{ conteos: [\{ modelo, capacidad, gb, cantidad \}] \} (or the matrix \{ modelos: [\{ modelo, combinaciones: [\{ capacidad, cantidad \}] \}] \}); a model or capacity it does not list has zero units. Requires integrations:write.

curl -X PUT https://api.vitrinadev.com/api/v1/integrations/external-stock-source \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://backoffice.example.cl/api/vitrina/stock",
    "token": "el-token-del-backoffice"
  }'

Example response (200)

{
  "data": {
    "configured": true,
    "url": "https://backoffice.example.cl/api/vitrina/stock",
    "token_preview": "a1b2••••9z",
    "updated_at": "2026-10-28T15:00:00.000Z"
  }
}

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

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.

Requires integrations:read or ads:read (owner, admin and the Marketing role); a supervisor or agent gets 403 here and never sees the card. The writes need integrations:write or ads:write.

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

Reads the 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).

Requires integrations:read or ads:read (owner, admin and the Marketing role — 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 partner token, so there is no scope to list ads under.

cursor is the provider’s own opaque keyset cursor (its pagination.cursor, a Meta ad id ascending), passed straight through as after. next_cursor is null once the provider 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 a provider 5xx, timeout, or a response shape this route does not recognise. 502 partner_token_invalid when the provider answers 401 on the WhatsApp partner token — that token is never auto-rotated; the dealer must reconnect WhatsApp.

ParameterInTypeRequiredConstraints
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"

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 the connection provider’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 the provider as return_url. An origin that is not the app’s is a 400 here rather than a silent substitution — the provider 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. The provider’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 a platform-wide provider 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/w/agencia/settings/integrations"
  }'

Example response (200)

{
  "data": {
    "url": "https://consent.example.com/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 the provider’s disconnect for the connection 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 the provider to revoke — pending / pending_selection with no connection — is cancelled LOCALLY: no provider call, the row moves straight to disconnected, and the pending hand-off simply expires on its own (the provider has no endpoint to cancel one early). A row already disconnected answers 200 unchanged (idempotent).

409 sync_in_flight when the provider 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 disconnect_refused when the provider’s own 403 says the delegated key is no longer good for this connection — also left unchanged. A 404 from the provider (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",
    "disconnected_at": "2026-09-23T15:10: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 the provider’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 (the provider’s Meta Ads connections read) 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 — the provider’s already_finalized: true on its own idempotent replay, reapplied through the same write path a fresh finalize uses.

A DIFFERENT candidate_id after the provider’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 the provider is ever called.

502 UPSTREAM_ERROR when the provider could not be read; the row is left unchanged and the call is safe to repeat.

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",
    "disconnected_at": null,
    "account": {
      "external_account_id": "act_1234567890",
      "name": "Automotora Ejemplo",
      "currency": "CLP",
      "timezone": "America/Santiago",
      "delivers_spend": true,
      "provider_connection_id": "c0c0c0c0-0000-4000-8000-000000000001",
      "atribu_connection_id": "c0c0c0c0-0000-4000-8000-000000000001"
    },
    "error": null,
    "handoff": null,
    "candidates": null,
    "updated_at": "2026-09-23T15:04:00.000Z"
  }
}

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

GET /integrations/meta-ads/pages

The Facebook Pages the Meta account can see, and which are tracked

Tracking a Page is what lets an ad video owned by that Page play inside the product instead of falling back to an embed. Pages are read live from Meta with the token the Meta Ads connection already holds — including Pages owned by a Business portfolio the Facebook user holds no direct role on.

needs_reconsent: true (with an empty available) is NOT an error: the Meta grant lacks the Page permissions (an older consent) or Meta no longer accepts the token. Restart POST /integrations/meta-ads/connect to grant them again; connected still lists what was already tracked.

409 meta_ads_not_connected when no ad account is connected. 502 UPSTREAM_ERROR when the provider could not answer.

Requires integrations:read or ads:read.

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

Example response (200)

{
  "data": {
    "available": [
      {
        "page_external_id": "109876543212345",
        "name": "Clínica Ejemplo",
        "picture_url": "https://cdn.example.com/pages/clinica.jpg",
        "instagram_username": "clinica.ejemplo",
        "connected": true
      }
    ],
    "connected": [
      {
        "page_external_id": "109876543212345",
        "name": "Clínica Ejemplo",
        "picture_url": "https://cdn.example.com/pages/clinica.jpg",
        "instagram_username": "clinica.ejemplo"
      }
    ],
    "needs_reconsent": false,
    "reconsent_reason": null
  }
}

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

PUT /integrations/meta-ads/pages

Choose the Facebook Pages to track

Tracks exactly the Pages in page_external_ids; Pages left out stop being tracked (nothing is deleted, so re-adding one is instant). Ids the Meta account cannot see are not saved and come back in unavailable_page_external_ids.

409 meta_ads_not_connected when no ad account is connected. 502 UPSTREAM_ERROR when the provider could not answer.

Requires integrations:write or ads:write.

curl -X PUT https://api.vitrinadev.com/api/v1/integrations/meta-ads/pages \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "page_external_ids": [
      "109876543212345"
    ]
  }'

Example response (200)

{
  "data": {
    "enabled_count": 1,
    "unavailable_page_external_ids": []
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 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 the provider 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 — the provider 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_&lt;status&gt;. 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 the provider, 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).

502 UPSTREAM_ERROR when the provider could not be read; the row is left unchanged and the call is safe to repeat.

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",
    "disconnected_at": null,
    "account": {
      "external_account_id": "act_1234567890",
      "name": "Automotora Ejemplo",
      "currency": "CLP",
      "timezone": "America/Santiago",
      "delivers_spend": true,
      "provider_connection_id": "c0c0c0c0-0000-4000-8000-000000000001",
      "atribu_connection_id": "c0c0c0c0-0000-4000-8000-000000000001"
    },
    "error": null,
    "handoff": null,
    "candidates": null,
    "updated_at": "2026-09-23T15:04:00.000Z"
  }
}

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

GET /integrations/woocommerce

Read the workspace’s WooCommerce connection

The connection card: the store, whether it is connected, the last health check (health.failure names what blocks it: not_https, permalinks_plain, rest_api_blocked, woocommerce_missing, credentials_rejected, webhooks_inactive, … with a Spanish health.message saying how to fix it), warnings (things that work but need attention, e.g. webhooks_delayed when the store’s WordPress cron delivers stock changes late), the webhooks registered at the store and the last import/reconcile. status: "not_connected" when the workspace never started a connect. Requires integrations:read.

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

Example response (200)

{
  "data": {
    "connected": true,
    "status": "connected",
    "store_url": "https://tienda.example.cl",
    "wc_version": "9.8.5",
    "currency": "CLP",
    "key_preview": "ck_a••••9f",
    "health": {
      "ok": true,
      "failure": null,
      "message": "Tu tienda está conectada y sincronizando.",
      "detail": null,
      "warnings": [],
      "store_url": "https://tienda.example.cl",
      "wc_version": "9.8.5",
      "checked_at": "2026-10-15T14:00:00.000Z"
    },
    "webhooks": [
      {
        "external_id": "41",
        "topic": "product.created",
        "status": "active"
      },
      {
        "external_id": "42",
        "topic": "product.updated",
        "status": "active"
      },
      {
        "external_id": "43",
        "topic": "product.deleted",
        "status": "active"
      },
      {
        "external_id": "44",
        "topic": "product.restored",
        "status": "active"
      },
      {
        "external_id": "45",
        "topic": "order.created",
        "status": "active"
      },
      {
        "external_id": "46",
        "topic": "order.updated",
        "status": "active"
      }
    ],
    "connected_at": "2026-10-15T13:58:12.000Z",
    "last_event_at": "2026-10-15T14:05:40.000Z",
    "last_event_lag_seconds": 12,
    "warnings": [],
    "last_sync_at": "2026-10-15T14:00:03.000Z",
    "last_sync_status": "success",
    "last_sync_error": null,
    "last_sync_summary": {
      "products": 48,
      "variants": 131,
      "skipped": 1,
      "deactivated": 0,
      "blocked": false
    }
  }
}

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

POST /integrations/woocommerce/check

Check the WooCommerce connection again

«Revisar conexión». For a connected store: re-activates or re-creates Vitrina’s webhooks there and re-checks the key, version and currency. For a pending one: re-runs the reachability probe. Returns the card. Requires integrations:write.

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

Example response (200)

{
  "data": {
    "connected": true,
    "status": "connected",
    "store_url": "https://tienda.example.cl",
    "wc_version": "9.8.5",
    "currency": "CLP",
    "key_preview": "ck_a••••9f",
    "health": {
      "ok": true,
      "failure": null,
      "message": "Tu tienda está conectada y sincronizando.",
      "detail": null,
      "warnings": [],
      "store_url": "https://tienda.example.cl",
      "wc_version": "9.8.5",
      "checked_at": "2026-10-15T14:00:00.000Z"
    },
    "webhooks": [
      {
        "external_id": "41",
        "topic": "product.created",
        "status": "active"
      },
      {
        "external_id": "42",
        "topic": "product.updated",
        "status": "active"
      },
      {
        "external_id": "43",
        "topic": "product.deleted",
        "status": "active"
      },
      {
        "external_id": "44",
        "topic": "product.restored",
        "status": "active"
      },
      {
        "external_id": "45",
        "topic": "order.created",
        "status": "active"
      },
      {
        "external_id": "46",
        "topic": "order.updated",
        "status": "active"
      }
    ],
    "connected_at": "2026-10-15T13:58:12.000Z",
    "last_event_at": "2026-10-15T14:05:40.000Z",
    "last_event_lag_seconds": 12,
    "warnings": [],
    "last_sync_at": "2026-10-15T14:00:03.000Z",
    "last_sync_status": "success",
    "last_sync_error": null,
    "last_sync_summary": {
      "products": 48,
      "variants": 131,
      "skipped": 1,
      "deactivated": 0,
      "blocked": false
    }
  }
}

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

POST /integrations/woocommerce/connect

Start connecting a WooCommerce store

Runs the connection health check against the store first. When it passes, returns authorize_url: WooCommerce’s own approval screen (/wc-auth/v1/authorize) for a HUMAN to open in the browser — the store then sends Vitrina a read/write REST key and the import starts. When it fails, authorize_url is null and health.failure names the cause. 409 when the workspace already has a different store connected. Requires integrations:write.

curl -X POST https://api.vitrinadev.com/api/v1/integrations/woocommerce/connect \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "store_url": "tienda.example.cl"
  }'

Example response (200)

{
  "data": {
    "authorize_url": "https://tienda.example.cl/wc-auth/v1/authorize?app_name=Vitrina&scope=read_write&user_id=…&return_url=…&callback_url=…",
    "health": {
      "ok": true,
      "failure": null,
      "message": "Tu tienda está lista para conectarse.",
      "detail": null,
      "warnings": [],
      "store_url": "https://tienda.example.cl",
      "wc_version": null,
      "checked_at": "2026-10-15T14:00:00.000Z"
    }
  }
}

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

POST /integrations/woocommerce/disconnect

Disconnect the WooCommerce store

Removes Vitrina’s webhooks from the store, forgets the key and stops offering the store’s products (their stock is no longer live). The products are kept; reconnecting the same store switches the live ones back on. Requires integrations:write.

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

Example response (200)

{
  "data": {
    "products_deactivated": 48
  }
}

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

POST /integrations/woocommerce/sync

Sincronizar ahora — reconcile the store’s catalog

Enqueues a full walk of the store (the same job the hourly reconcile runs): products, variations and stock, then products the store no longer lists are switched off — unless that looks like a mass drop, in which case nothing is switched off (last_sync_status: "blocked") until force: true. Requires integrations:write.

curl -X POST https://api.vitrinadev.com/api/v1/integrations/woocommerce/sync \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "force": false
  }'

Example response (202)

{
  "data": {
    "enqueued": true
  }
}

Answers: 202 · 400 · 401 · 403 · 404 · 409 · 429

On this page