Sandbox-Simulate header
Drive deterministic outcomes (paid / failed / expired / chargeback / slow_payment) from CI via a single request header. No UI needed.
The playground at /sandbox/login is great for hands-on debugging, but CI / automated tests need a programmatic way to drive outcomes. Pass the Sandbox-Simulate header on POST /api/v1/payments and we schedule the transition for you — the same state machine the playground uses, no clicks required.
Accepted values
| Value | Delay | Outcome | Webhooks fired |
|---|---|---|---|
| paid | 5 s | pending → completed | payment.completed |
| failed | 5 s | pending → failed | payment.failed |
| expired | 30 s | pending → expired | payment.failed |
| chargeback | 30 s + 5 s | pending → completed (+ dispute claim) | payment.completed, chargeback.created |
| slow_payment | 5 min | pending → completed (very late) | payment.completed |
| missing_fields | immediate | 422 missing_required_fields — no transaction is created | none |
Simulating the missing-field 422
A production rail can reject a charge because a buyer field is missing — PIX needs the payer's CPF, OXXO needs an RFC/CURP and a phone. The API answers 422 missing_required_fields with details.missingFields naming each one, and a direct-API integrator has to handle it. Sandbox rails are synthetic, so that path never fired here — you would write the handler blind and find out in production.
Sandbox-Simulate: missing_fields returns that exact response so you can test your handler. It fires even if you sent the field — it is an explicit simulation, not a validation. Which fields it names comes from the rail's requiredFields, published on every sandbox item of GET /api/v1/payment-methods, so you can see up front what each rail will ask for in production.
requiredFields is informational, never a sandbox validation. Providers for the same method and country differ — SPEI asks for a document with one provider and not with another — so the rails only declare a field where the real providers agree, and the authoritative answer always comes from the charge itself.Usage
# Create a payment that auto-pays in 5 seconds:
curl https://sandbox.key2pay.ai/api/v1/payments \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-H "Sandbox-Simulate: paid" \
-d '{ "amount": 50, "paymentMethodId": "sbx_spei", "country": "MEX" }'
# Create a payment that auto-fails in 5 seconds:
curl https://sandbox.key2pay.ai/api/v1/payments \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Sandbox-Simulate: failed" \
-d '{ … }'
# Create a payment that completes then disputes — perfect for testing
# your chargeback.created webhook handler:
curl https://sandbox.key2pay.ai/api/v1/payments \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Sandbox-Simulate: chargeback" \
-d '{ … }'Typical CI pattern
// Pattern: create tx with simulate, poll until terminal status, assert.
async function testHappyPath() {
// Spin up a local webhook receiver before this test (e.g. ngrok),
// register it once during test setup, share across tests.
const created = await api.post("/payments", {
headers: { "Sandbox-Simulate": "paid" },
body: { amount: 50, paymentMethodId: "sbx_spei", country: "MEX" },
});
expect(created.status).toBe("pending");
// Wait for the webhook (up to 10s — the simulate fires at 5s):
const event = await webhookReceiver.next("payment.completed", 10000);
expect(event.data.id).toBe(created.transactionId);
// Confirm the tx now reads as completed:
const fetched = await api.get(`/payments/${created.transactionId}`);
expect(fetched.status).toBe("completed");
}