Outbound approvals
Messages waiting for a person to approve them before they go out, from any rail.
Download the full API pública projection: openapi.json.
| Method | Path | What it does |
|---|---|---|
GET | /outbound/approvals | List proactive messages waiting on a person |
GET | /outbound/approvals/{id} | Read one pending approval |
POST | /outbound/approvals/{id}/approve | Approve (and optionally edit) a pending message |
POST | /outbound/approvals/{id}/discard | Discard a pending message, undoable for 5 minutes |
POST | /outbound/approvals/{id}/dismiss | Refuse a pending message and stop the follow-up |
POST | /outbound/approvals/{id}/reject | Refuse a pending message |
POST | /outbound/approvals/{id}/undo | Undo an approval or discard inside its window |
POST | /outbound/approvals/batch/approve | Approve many pending messages with one undo window |
POST | /outbound/approvals/batch/discard | Discard many pending messages (undoable) |
POST | /outbound/approvals/batch/undo | Undo many scheduled decisions |
GET | /outbound/approvals/scheduled | Decisions 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
rail | query | service_lifecycle \ | recontacto \ | seguimiento \ |
assignee_user_id | query | uuid | no | |
limit | query | integer | no | ≥ 1, ≤ 200 |
curl https://api.vitrinadev.com/api/v1/outbound/approvals \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl https://api.vitrinadev.com/api/v1/outbound/approvals/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
message | string | null | — | mín. 1, máx. 4000 |
subject | string | null | — | mín. 1, máx. 78 |
note | string | 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"
}'Example response (200)
{
"data": {
"outcome": "scheduled",
"actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
"reason": null,
"runId": null,
"releaseAt": "2026-11-03T12:10:00.000Z",
"releaseKind": "approve"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
reason | string | null | — | máx. 1000 |
reason_code | wrong_moment \ | wrong_wording \ | already_handled \ |
stop | boolean | — |
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
}'Example response (200)
{
"data": {
"outcome": "scheduled",
"actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
"reason": null,
"runId": null,
"releaseAt": "2026-11-03T12:10:00.000Z",
"releaseKind": "discard"
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
reason | string | 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"
}'Example response (200)
{
"data": {
"outcome": "rejected",
"actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
"reason": "Ya compró en la tienda",
"stopped": true
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
reason | string | null | — | máx. 1000 |
reason_code | wrong_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
}'Example response (200)
{
"data": {
"outcome": "rejected",
"actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
"reason": "wrong_moment",
"stopped": false
}
}Answers: 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.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | uuid | yes |
curl -X POST https://api.vitrinadev.com/api/v1/outbound/approvals/<id>/undo \
-H "Authorization: Bearer $VITRINA_KEY"Example response (200)
{
"data": {
"outcome": "deferred",
"actionId": "5c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
"reason": "undone",
"runId": null,
"undone": true
}
}Answers: 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.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
ids | uuid[] | — | |
all | true | — | |
rail | service_lifecycle \ | recontacto \ | seguimiento \ |
purpose | string | — | mín. 1, máx. 200 |
note | string | 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"
}'Example response (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
}
]
}
}Answers: 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.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
ids | uuid[] | — | |
all | true | — | |
rail | service_lifecycle \ | recontacto \ | seguimiento \ |
purpose | string | — | mín. 1, máx. 200 |
reason | string | null | — | máx. 1000 |
reason_code | wrong_moment \ | wrong_wording \ | already_handled \ |
stop | boolean | — |
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"
}'Example response (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
}
]
}
}Answers: 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.
Body
| Field | Type | Required | Constraints |
|---|---|---|---|
ids | uuid[] | yes |
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"
]
}'Example response (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
}
]
}
}Answers: 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 \}] \}.
| Parameter | In | Type | Required | Constraints |
|---|---|---|---|---|
limit | query | integer | no | ≥ 1, ≤ 500 |
curl https://api.vitrinadev.com/api/v1/outbound/approvals/scheduled \
-H "Authorization: Bearer $VITRINA_KEY"Example response (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?"
}
]
}
}Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429
Legal identity
The dealership's legal identity (legal name, RUT, line of business, address, representative and deed) and its powers of attorney. Without it, printing a sale note or a compraventa answers 409.
Clinic payment registrations
The Mercado Pago payments still to be recorded in the clinic's practice system: the task list, confirming which practice-system payment matches each one or closing it without a match, and which payment method stands for Mercado Pago.