VitrinaAPI

Por aprobar

Los mensajes que esperan el OK de una persona antes de salir, de cualquier camino.

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/outbound/approvalsList proactive messages waiting on a person
GET/outbound/approvals/{id}Read one pending approval
POST/outbound/approvals/{id}/approveApprove (and optionally edit) a pending message
POST/outbound/approvals/{id}/discardDiscard a pending message, undoable for 5 minutes
POST/outbound/approvals/{id}/dismissRefuse a pending message and stop the follow-up
POST/outbound/approvals/{id}/rejectRefuse a pending message
POST/outbound/approvals/{id}/undoUndo an approval or discard inside its window
POST/outbound/approvals/batch/approveApprove many pending messages with one undo window
POST/outbound/approvals/batch/discardDiscard many pending messages (undoable)
POST/outbound/approvals/batch/undoUndo many scheduled decisions
GET/outbound/approvals/scheduledDecisions inside their 5-minute undo window

GET /outbound/approvals

List proactive messages waiting on a person

Every composed outbound message this workspace is holding for a human decision, oldest OBLIGATION first — ordered by due_at rather than by when the draft was written, because what is running out of time is the obligation, not the proposal. Each entry carries the draft exactly as it would be sent, the model's operator-facing reason (never shown to a customer), who was asked, and expired: true once the obligation passed its lateness bound — those stay in the list on purpose, so a reviewer sees that one ran out of time rather than finding it silently gone. expiresAt is that bound: dueAt plus the action’s maximum lateness, as an ISO timestamp, or null when the action has no limit and never expires. Filter by rail for one rail's queue, or by assignee_user_id for one person's. An entry with a null assignee_user_id was routed to the workspace admins, which is a real answer and not a missing one. Rail api holds the sends a CONNECTED APP made past its fan-out limit (ADR 0106 §3.2); those entries carry via: \{ kind, name \} — the app that wrote them («vía Claude») — and via is null on every other rail. whyNow is the owner-facing reason, in the product's register (null when the composer wrote none; reason stays audit material). Rail recontacto entries carry recontacto: \{ attempt_id, step_label, auto_send_at, auto_send_at_label, template, window_closes_at, window_note \}: the step as «Si no responde» names it and, for a handed-off conversation's draft, when it sends itself if nobody reviews it («mañana 18:00», the conversation strip's own words), and whether the draft rides an approved WhatsApp template; for a free-text WhatsApp or Instagram draft, when its 24 h window closes (window_closes_at, tenant clock) and that in the owner’s words (window_note: «Envíalo antes de mañana 14:05; después sale como plantilla aprobada»). Approving a WhatsApp draft past its window re-composes it as an approved template (outcome deferred when it must wait for one); an Instagram one past its window is dropped (outcome stale, G53). due_at is not that moment. Null on every other rail.

ParámetroEnTipoObligatorioRestricciones
railqueryservice_lifecycle \recontacto \seguimiento \
assignee_user_idqueryuuidno
limitqueryintegerno≥ 1, ≤ 200
curl https://api.vitrinadev.com/api/v1/outbound/approvals \
  -H "Authorization: Bearer $VITRINA_KEY"

Ejemplo de respuesta (200)

{
  "data": {
    "items": [
      {
        "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
        "rail": "recontacto",
        "purpose": "recontacto_stalled",
        "obligationClass": "follow_up",
        "contactId": "6d2e3f4a-5b6c-4d7e-8f90-1a2b3c4d5e6f",
        "subjectType": "lead",
        "subjectId": "7e3f4a5b-6c7d-4e8f-901a-2b3c4d5e6f70",
        "conversationId": "8f4a5b6c-7d8e-4f90-a12b-3c4d5e6f7081",
        "dueAt": "2026-11-03T13:00:00.000Z",
        "requestedAt": "2026-11-03T12:05:00.000Z",
        "assigneeUserId": "9a5b6c7d-8e9f-4a01-b23c-4d5e6f708192",
        "message": "Hola Camila, te escribo por el iPhone 15 Pro que viste. Te sigue interesando?",
        "subject": null,
        "reason": "Asked about the 15 Pro two days ago and did not answer the price.",
        "whyNow": "Preguntó por el iPhone 15 Pro hace dos días y no respondió.",
        "recontacto": {
          "attempt_id": "ab6c7d8e-9f0a-4b12-834d-5e6f708192a3",
          "step_label": "Conversó y dejó de responder · paso 3 de 4",
          "auto_send_at": null,
          "auto_send_at_label": null,
          "template": true,
          "handoff": null,
          "window_closes_at": null,
          "window_note": null
        },
        "channel": "whatsapp",
        "via": null,
        "decisionVersion": 1,
        "expired": false,
        "expiresAt": "2026-11-10T13:00:00.000Z"
      }
    ],
    "total": 1
  }
}

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

GET /outbound/approvals/{id}

Read one pending approval

One queue entry by outbound_action id. 404 both when it does not exist and when it belongs to another workspace — the difference is exactly what an existence oracle would leak.

ParámetroEnTipoObligatorioRestricciones
idpathuuidsí
curl https://api.vitrinadev.com/api/v1/outbound/approvals/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Ejemplo de respuesta (200)

{
  "data": {
    "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
    "rail": "recontacto",
    "purpose": "recontacto_stalled",
    "obligationClass": "follow_up",
    "contactId": "6d2e3f4a-5b6c-4d7e-8f90-1a2b3c4d5e6f",
    "subjectType": "lead",
    "subjectId": "7e3f4a5b-6c7d-4e8f-901a-2b3c4d5e6f70",
    "conversationId": "8f4a5b6c-7d8e-4f90-a12b-3c4d5e6f7081",
    "dueAt": "2026-11-03T13:00:00.000Z",
    "requestedAt": "2026-11-03T12:05:00.000Z",
    "assigneeUserId": "9a5b6c7d-8e9f-4a01-b23c-4d5e6f708192",
    "message": "Hola Camila, te escribo por el iPhone 15 Pro que viste. Te sigue interesando?",
    "subject": null,
    "reason": "Asked about the 15 Pro two days ago and did not answer the price.",
    "whyNow": "Preguntó por el iPhone 15 Pro hace dos días y no respondió.",
    "recontacto": {
      "attempt_id": "ab6c7d8e-9f0a-4b12-834d-5e6f708192a3",
      "step_label": "Conversó y dejó de responder · paso 3 de 4",
      "auto_send_at": null,
      "auto_send_at_label": null,
      "template": true,
      "handoff": null,
      "window_closes_at": null,
      "window_note": null
    },
    "channel": "whatsapp",
    "via": null,
    "decisionVersion": 1,
    "expired": false,
    "expiresAt": "2026-11-10T13:00:00.000Z"
  }
}

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

POST /outbound/approvals/{id}/approve

Approve (and optionally edit) a pending message

Records the approval; the message is RELEASED 5 minutes later unless undone (POST /outbound/approvals/\{id\}/undo), and the response is outcome: "scheduled" with releaseAt (the undo deadline) and releaseKind: "approve". The approved entry leaves the pending list and count immediately. At release it goes — through the shared executor, which re-runs the full policy kernel in enforce mode and re-checks freshness immediately before the provider. Approving is therefore a decision to send, not an instruction to bypass anything. When released, the outcome (visible afterwards on the action) is: sent; stale (the customer wrote while the proposal waited — nothing was sent and the obligation went back for a fresh evaluation); deferred (quiet hours, a lost arbitration, a transport backoff — it will come back on its own); or blocked (the kernel or the provider refused, and reason says which). An optional message replaces the draft text and NOTHING else: not the purpose, the legal basis, the channel or the obligation class, which came from the protocol and are not editable through a review screen. Recontacto proposals refuse edits outright — that rail's drafts can be approved WhatsApp templates, whose body belongs to Meta. So do api drafts: a member approves what the connected app wrote, or refuses it. Approving one waives its Advertencias (the approving member is the acknowledgment) and still stops on any Bloqueo. Requires outbound_approvals:write, campaigns:write AND messages:send. 409 when the entry is no longer pending, or when the obligation passed its lateness bound.

ParámetroEnTipoObligatorioRestricciones
idpathuuidsí

Cuerpo

CampoTipoObligatorioRestricciones
messagestring | null—mín. 1, máx. 4000
subjectstring | null—mín. 1, máx. 78
notestring | null—máx. 1000
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/<id>/approve \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": null,
    "note": "Revisado"
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "outcome": "scheduled",
    "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
    "reason": null,
    "runId": null,
    "releaseAt": "2026-11-03T12:10:00.000Z",
    "releaseKind": "approve"
  }
}

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

POST /outbound/approvals/{id}/discard

Discard a pending message, undoable for 5 minutes

Like /reject (or /dismiss with stop: true) but the message leaves the pending list at once and the cancellation is applied after the 5-minute window, so POST /outbound/approvals/\{id\}/undo brings it back. Response outcome: "scheduled", releaseKind: "discard", releaseAt. Requires outbound_approvals:write.

ParámetroEnTipoObligatorioRestricciones
idpathuuidsí

Cuerpo

CampoTipoObligatorioRestricciones
reasonstring | null—máx. 1000
reason_codewrong_moment \wrong_wording \already_handled \
stopboolean—
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/<id>/discard \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason_code": "wrong_moment",
    "reason": null
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "outcome": "scheduled",
    "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
    "reason": null,
    "runId": null,
    "releaseAt": "2026-11-03T12:10:00.000Z",
    "releaseKind": "discard"
  }
}

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

POST /outbound/approvals/{id}/dismiss

Refuse a pending message and stop the follow-up

«Descartar seguimiento». Cancels the action exactly as /reject does, and tells the rail to stop the follow-up too. Rail recontacto: the person's ladder halts (G33 operator_dismissed): no later step of this path is drafted or sent; a new message from the customer starts a new episode. Rails with no notion of stopping treat it as a refusal. The response is the rejection, with stopped: true when the follow-up stopped. Requires only outbound_approvals:write.

ParámetroEnTipoObligatorioRestricciones
idpathuuidsí

Cuerpo

CampoTipoObligatorioRestricciones
reasonstring | null—máx. 1000
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/<id>/dismiss \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Ya compró en la tienda"
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "outcome": "rejected",
    "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
    "reason": "Ya compró en la tienda",
    "stopped": true
  }
}

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

POST /outbound/approvals/{id}/reject

Refuse a pending message

Cancels the action, with the reviewer and the reason recorded on it. The action is CANCELLED rather than left in the queue on purpose: a refused proposal that stays pending is a proposal the next drain can execute. It does not stop the run — later steps of the same protocol are separate obligations and are unaffected (use the service-lifecycle stop or pause verbs for that). Requires only outbound_approvals:write: refusing to send is never the dangerous direction. Rail recontacto: reason_code wrong_moment / wrong_wording (or free text) drops this message only and the follow-up continues (G53 draft_rejected); already_handled / should_not_contact — or a reason that says so, such as «A esta persona no hay que escribirle» — also stops the follow-up (G33 operator_dismissed), as /dismiss does. The response says which in stopped.

ParámetroEnTipoObligatorioRestricciones
idpathuuidsí

Cuerpo

CampoTipoObligatorioRestricciones
reasonstring | null—máx. 1000
reason_codewrong_moment \wrong_wording \already_handled \
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/<id>/reject \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason_code": "wrong_moment",
    "reason": null
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "outcome": "rejected",
    "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
    "reason": "wrong_moment",
    "stopped": false
  }
}

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

POST /outbound/approvals/{id}/undo

Undo an approval or discard inside its window

Returns a scheduled approval or discard to the pending list. 409 once the 5-minute window closed (the release was claimed) or when nothing was scheduled. Exactly one of undo and release wins, never both. Requires outbound_approvals:write.

ParámetroEnTipoObligatorioRestricciones
idpathuuidsí
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/<id>/undo \
  -H "Authorization: Bearer $VITRINA_KEY"

Ejemplo de respuesta (200)

{
  "data": {
    "outcome": "deferred",
    "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
    "reason": "undone",
    "runId": null,
    "undone": true
  }
}

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

POST /outbound/approvals/batch/approve

Approve many pending messages with one undo window

Body: exactly one of ids (selected, max 500) or all: true (every message the approval queue shows, the same predicate as the pending count, narrowed optionally by rail and purpose = the step; capped at 500 per call, truncated says more remain). Each item is released 5 minutes after the call unless undone, and goes through the same gates as a single approval (contact hours, pauses, policy). Response: \{ requested, scheduled, undone, skipped, truncated, items: [\{ actionId, status: "scheduled" | "undone" | "skipped", kind, releaseAt, reason \}] \} - one outcome per item; a skipped item (decided elsewhere, expired, unknown) never fails the rest. Requires outbound_approvals:write AND campaigns:write.

Cuerpo

CampoTipoObligatorioRestricciones
idsuuid[]—
alltrue—
railservice_lifecycle \recontacto \seguimiento \
purposestring—mín. 1, máx. 200
notestring | null—máx. 1000
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/batch/approve \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "all": true,
    "rail": "recontacto",
    "purpose": "recontacto_stalled"
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "requested": 1,
    "scheduled": 1,
    "undone": 0,
    "skipped": 0,
    "truncated": false,
    "items": [
      {
        "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
        "status": "scheduled",
        "kind": "approve",
        "releaseAt": "2026-11-03T12:10:00.000Z",
        "reason": null
      }
    ]
  }
}

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

POST /outbound/approvals/batch/discard

Discard many pending messages (undoable)

Same selector as batch approve. Items leave the pending list at once; the cancellation applies after the 5-minute window unless undone. Response: \{ requested, scheduled, undone, skipped, truncated, items: [\{ actionId, status: "scheduled" | "undone" | "skipped", kind, releaseAt, reason \}] \} - one outcome per item; a skipped item (decided elsewhere, expired, unknown) never fails the rest. Requires outbound_approvals:write.

Cuerpo

CampoTipoObligatorioRestricciones
idsuuid[]—
alltrue—
railservice_lifecycle \recontacto \seguimiento \
purposestring—mín. 1, máx. 200
reasonstring | null—máx. 1000
reason_codewrong_moment \wrong_wording \already_handled \
stopboolean—
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/batch/discard \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [
      "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e"
    ],
    "reason_code": "wrong_moment"
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "requested": 1,
    "scheduled": 1,
    "undone": 0,
    "skipped": 0,
    "truncated": false,
    "items": [
      {
        "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
        "status": "scheduled",
        "kind": "approve",
        "releaseAt": "2026-11-03T12:10:00.000Z",
        "reason": null
      }
    ]
  }
}

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

POST /outbound/approvals/batch/undo

Undo many scheduled decisions

Undo approvals/discards inside their window. Response: \{ requested, scheduled, undone, skipped, truncated, items: [\{ actionId, status: "scheduled" | "undone" | "skipped", kind, releaseAt, reason \}] \} - one outcome per item; a skipped item (decided elsewhere, expired, unknown) never fails the rest. Requires outbound_approvals:write.

Cuerpo

CampoTipoObligatorioRestricciones
idsuuid[]sí
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/batch/undo \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [
      "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e"
    ]
  }'

Ejemplo de respuesta (200)

{
  "data": {
    "requested": 1,
    "scheduled": 0,
    "undone": 1,
    "skipped": 0,
    "truncated": false,
    "items": [
      {
        "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
        "status": "undone",
        "kind": null,
        "releaseAt": null,
        "reason": null
      }
    ]
  }
}

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

GET /outbound/approvals/scheduled

Decisions inside their 5-minute undo window

Approvals and discards recorded in the last 5 minutes and not yet released, soonest releaseAt first. Each can be undone until releaseAt. Response: \{ graceSeconds, items: [\{ actionId, rail, kind: "approve" | "discard", releaseAt, contactId, message \}] \}.

ParámetroEnTipoObligatorioRestricciones
limitqueryintegerno≥ 1, ≤ 500
curl https://api.vitrinadev.com/api/v1/outbound/approvals/scheduled \
  -H "Authorization: Bearer $VITRINA_KEY"

Ejemplo de respuesta (200)

{
  "data": {
    "graceSeconds": 300,
    "items": [
      {
        "actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
        "rail": "recontacto",
        "kind": "approve",
        "releaseAt": "2026-11-03T12:10:00.000Z",
        "contactId": "6d2e3f4a-5b6c-4d7e-8f90-1a2b3c4d5e6f",
        "message": "Hola Camila, te escribo por el iPhone 15 Pro que viste. Te sigue interesando?"
      }
    ]
  }
}

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

En esta página