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_WARNINGabove comes from a sandbox. The policy is evaluated before the capture happens. Your integration learns to handle a422 OUTBOUND_BLOCKEDor a409 OUTBOUND_WARNINGagainst synthetic data, instead of tripping over the first one only in production. - So does idempotency. Resending the same
idempotency_keyreturns 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.