VitrinaAPI

Run your integration without touching real data

What a sandbox is, how to get an sk_test_ key, and what gets captured.

Every workspace can have its own sandbox. It is a synthetic copy, separate from your real data. Nothing inside it stands in for a real person.

The responses on this page come from a run against a sandbox. Your identifiers will be different; the shape is the same.

The examples keep the Spanish run's values

The run behind this page went against a Spanish-speaking workspace. The tenant names, the message text and the error messages the API wrote back in Spanish are pasted here exactly as they came. Only the prose around them is English.

Trap

An sk_test_ key never reads your real workspace, and a live one never reads the sandbox

An sk_test_ key only works in your sandbox, and a live key only in your real workspace. Using one on the other side answers 401. There is no URL, no parameter and no header to cross over: the key you use decides.

Get an sk_test_ key

POST /api-keys with livemode: false mints a test key. The tenant_id it hands back is your sandbox's, not your workspace's. The key lives there, even though you asked for it from the real one.

Without a sandbox yet, the answer is this:

{
  "error": {
    "code": "SANDBOX_NOT_PROVISIONED",
    "message": "Este espacio de trabajo todavía no tiene un entorno de prueba. Créalo primero y luego genera la clave de prueba.",
    "details": {
      "provision": ["POST /api/v1/sandbox/automotive", "POST /api/v1/sandbox/clinic"]
    },
    "requestId": "5e2b1b14-e657-4d6e-9ade-61bdec0112e2"
  }
}

The error itself says what to call. Asking for a key does not create the sandbox on its own.

POST /sandbox/automotive creates a synthetic dealership. It brings vehicles with real makes and models, buyers, conversations, quotes and reservations. It also hands back a ready test key:

curl -X POST https://api.vitrinadev.com/api/v1/sandbox/automotive \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "seed": "mi-demo" }'
{
  "data": {
    "created": true,
    "tenant": {
      "id": "34d213e0-5cac-4c86-a57c-4db28d59b9ec",
      "name": "Autos Alameda (demostración)",
      "slug": "autos-alameda-demo-3"
    },
    "vertical": "automotive",
    "seed": "mi-demo",
    "stats": {
      "vehicles": 40,
      "contacts": 30,
      "leads": 30,
      "quotes": 10,
      "reservations": 5,
      "…": "…"
    },
    "test_key": {
      "secret": "sk_test_gZCK…",
      "prefix": "sk_test_gZCK",
      "scopes": ["marketplace:read", "marketplace:write", "leads:read", "leads:write", "contacts:read", "contacts:write", "conversations:read", "conversations:write", "messages:send", "quotes:read", "quotes:write", "reservations:read", "reservations:write", "sandbox:read"],
      "livemode": false
    }
  }
}

seed is the only thing that decides the content. The same seed rebuilds the same lot, with the same buyers. Calling it again answers 200 with created: false and no new key. There is one sandbox per workspace, whichever vertical got there first.

Once the sandbox exists, POST /api-keys with livemode: false mints as many test keys as you need. Pick whichever scopes you need:

curl -X POST https://api.vitrinadev.com/api/v1/api-keys \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "integración — pruebas", "scopes": ["tenant:read"], "livemode": false }'
{
  "data": {
    "id": "11773f0e-f653-4e2f-8f9d-5c67d0a3eb88",
    "tenant_id": "34d213e0-5cac-4c86-a57c-4db28d59b9ec",
    "name": "integración — pruebas",
    "prefix": "sk_test_x_-L",
    "scopes": ["tenant:read"],
    "revoked_at": null,
    "livemode": false,
    "secret": "sk_test_x_-L…"
  }
}

GET /api-keys lists both families together. livemode tells them apart, so you manage both from one screen:

{
  "data": [
    { "id": "11773f0e-…", "name": "integración — pruebas", "prefix": "sk_test_x_-L", "livemode": false, "…": "…" },
    { "id": "8cd24c44-…", "name": "workspace owner's token", "prefix": "sk_vyJfH", "livemode": true, "…": "…" }
  ],
  "meta": { "total": 2 }
}

What gets captured, and what gets delivered

A sandbox never opens a real channel. Messaging, e-mail, marketplace publishing and electronic-document emission all go through a mocked upstream. They get captured, never sent.

Webhooks are the one exception. They get delivered for real to your URL, with "livemode": false in the body and the Vitrina-Livemode: false header. The full mechanism is in Webhooks.

A send attempted from a sandbox conversation goes through the same outbound policy a real workspace's send does:

{
  "error": {
    "code": "OUTBOUND_WARNING",
    "message": "This message was not sent yet: the outbound policy warns about quiet_hours, conversation_human_owned. Resend the same request with \"acknowledge\": [\"quiet_hours\",\"conversation_human_owned\"] to send it anyway; the acknowledgment is recorded.",
    "details": {
      "reasons": [
        { "code": "quiet_hours", "kind": "advertencia", "detail": { "windowStartHour": 9, "windowEndHour": 20 } },
        { "code": "conversation_human_owned", "kind": "advertencia", "detail": { "conversationId": "f6506b7f-a342-48d6-8590-011f263f6b13" } }
      ]
    },
    "requestId": "d5ab5a84-3580-44ec-a11d-3ada6953f651"
  }
}

Resent with acknowledge, the same call answers 201 with the message persisted. Instead of going out over WhatsApp, it gets logged. GET /sandbox/outbound lists what was captured, with a per-rail summary:

{
  "data": [
    {
      "id": "0aab045e-1441-41f3-9258-8a780c501b1a",
      "tenant_id": "34d213e0-5cac-4c86-a57c-4db28d59b9ec",
      "rail": "whatsapp",
      "provider": "whatsapp_cloud",
      "destination": "56908450000",
      "body": "Hola, te confirmo tu cita para el jueves a las 10.",
      "origin": "provider.sendTextMessage",
      "idempotency_key": "74ec9181-edf1-48dc-aab3-4883c7eb2e2e",
      "captured_at": "2026-09-23T05:28:53.464Z"
    }
  ],
  "meta": {
    "summary": [{ "rail": "whatsapp", "count": 1 }],
    "rails": ["whatsapp", "email", "instagram", "messenger", "webchat", "voice", "tax_document", "portal", "other"]
  }
}

GET /sandbox/outbound/{id} returns one row. It carries the whole payload the attempted rail was given. Use it to compare, byte for byte, against what your integration thinks it sent.

What test mode does not fake

  • The send policy's blocks and warnings run the same way. The 409 OUTBOUND_WARNING above comes from a sandbox. The policy is evaluated before the capture happens. Your integration learns to handle a 422 OUTBOUND_BLOCKED or a 409 OUTBOUND_WARNING against synthetic data, instead of tripping over the first one only in production.
  • So does idempotency. Resending the same idempotency_key returns the original capture. It never writes a second one: the row above carries the same id as the message that produced it.
  • The call ceiling is the same limiter, per key, live or test alike. Authentication has the detail.

What has no way to reproduce is an external provider's own limit. A messaging channel's own quality-based throttle does not exist in test mode, and there is no synthetic value that teaches how long it takes to show up.

The permanent gaps

Voice is the one surface with no equivalent capture. An outbound call leaves a row and a conversation in the sandbox. The attempt gets logged, but no number is ever dialled.

Chileautos' panel cannot be connected inside a sandbox at all. It cannot even be used just to capture its publications:

{
  "error": {
    "code": "SANDBOX_CHANNEL_FORBIDDEN",
    "message": "Este es un espacio de demostración con datos sintéticos: no se pueden conectar canales reales. Los envíos quedan registrados en «Envíos capturados» en lugar de salir.",
    "details": {
      "attempted": "chileautos_panel",
      "reason": "El panel de Chileautos se opera con un navegador real y una sesión de proxy: no se puede simular en un espacio de demostración. Usa la integración Chileautos (API), cuyas publicaciones quedan capturadas."
    },
    "requestId": "42b11cd8-09d3-4155-ac75-380ad413563e"
  }
}

Chileautos' REST integration does work in test mode, and its publications show up on the portal rail.

Reset the sandbox

POST /sandbox/reset deletes the synthetic data and every capture, and regenerates a fresh batch from the same seed:

{
  "data": {
    "tenant_id": "34d213e0-5cac-4c86-a57c-4db28d59b9ec",
    "vertical": "automotive",
    "seed": "mi-demo",
    "counts": { "vehicle": 40, "contact": 30, "lead": 30, "…": "…" },
    "deleted": { "vehicle": 40, "contact": 30, "sandbox_outbound_capture": 1, "…": "…" }
  }
}

deleted.sandbox_outbound_capture is the proof: every send you had captured goes with the rest. What survives is the workspace, its members and its keys. The sk_test_ ones keep working after the reset, and so do its webhook subscriptions and its free plan. Dates get re-anchored to the day of the reset and the visible ids (L-1, C-1…) start over. It can only be called from the real workspace that provisioned it, or from a key of the sandbox itself.

On this page