Sandbox

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.

The header is honoured ONLY in sandbox. In production it's silently ignored — a misconfigured test suite can't accidentally drive live data.

Accepted values

ValueDelayOutcomeWebhooks fired
paid5 spending → completedpayment.completed
failed5 spending → failedpayment.failed
expired30 spending → expiredpayment.failed
chargeback30 s + 5 spending → completed (+ dispute claim)payment.completed,
chargeback.created
slow_payment5 minpending → completed (very late)payment.completed
missing_fieldsimmediate422 missing_required_fields — no transaction is creatednone

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

bash
# 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

javascript
// 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");
}