Standalone PSP/Bank sandbox provider API with deterministic scenarios, mock 3DS flow, and signed webhooks.
Base URL: https://banksandboxapi.shake-out.com/api
This sandbox simulates the provider/bank side only (no checkout UI). It accepts an opaque encrypted_card_payload, runs a deterministic scenario engine (approve/decline/3DS/timeout/error), enforces a strict transaction state machine, exposes mock 3DS challenge pages, and can dispatch signed webhooks.
Contract rule: Never return or log sensitive card data. The sandbox never stores decrypted PAN/CVV/expiry, and never persists the encrypted payload itself.
Controlled by env: SANDBOX_AUTH_MODE
api_keySend header:
X-Api-Key: <your key>noneNo auth header required.
Unauthorized response:
{
"error": "UNAUTHORIZED",
"message": "API key missing or invalid",
"details": {}
}
Supported on:
POST /psp/payments/authorizeHeader:
Idempotency-Key: <string>Behavior:
Idempotency-Key and merchant_order_id are sent again, the sandbox returns the original response (same transaction_id, status, etc.).Idempotency-Key matches but merchant_order_id differs, treat it as a conflict and return 422.Example conflict:
{
"error": "IDEMPOTENCY_CONFLICT",
"message": "Idempotency-Key was already used with a different merchant_order_id",
"details": {
"idempotency_key": "abc",
"existing_merchant_order_id": "ORD-1",
"incoming_merchant_order_id": "ORD-2"
}
}
amount is a decimal with up to 2 fractional digits.encrypted_card_payload.If you describe scope externally, be careful: the behavior is out-of-scope-like, but the real PCI scope depends on your full system boundary and assessor interpretation.
All error responses must follow:
{
"error": "ERROR_CODE",
"message": "Human readable message",
"details": {}
}
Common HTTP codes:
401 UNAUTHORIZED404 NOT_FOUND422 UNPROCESSABLE_ENTITY500 INTERNAL_SERVER_ERROR504 GATEWAY_TIMEOUTAPPROVEDDECLINED3DS_REQUIREDCAPTUREDREFUNDEDValid transitions:
APPROVED → CAPTUREDCAPTURED → REFUNDED3DS_REQUIRED → APPROVED3DS_REQUIRED → DECLINEDAny invalid transition returns 422 UNPROCESSABLE_ENTITY.
Example:
{
"error": "INVALID_STATE_TRANSITION",
"message": "Cannot refund a payment that is not CAPTURED",
"details": {
"transaction_id": "tx_xxx",
"current_status": "APPROVED",
"required_status": "CAPTURED"
}
}
Scenario engine is deterministic. You can force behavior via request field:
scenario: approve | decline | 3ds | timeout | errorNotes:
scenario is omitted, sandbox may default to approve (recommended) or a configured default.timeout returns 504.error returns 500.3ds returns 3DS_REQUIRED and provides acs_url.If webhook_url is provided, the sandbox sends payment.updated when a transaction is created or updated (approve/decline/3DS required/capture/refund).
Delivery rules:
Timeout: 3 seconds
Retries: 2 (backoff 1s then 3s)
Retries happen only on:
No retries for any 2xx.
Controlled by env:
WEBHOOK_SIGNING_MODE=hmac|stripeWEBHOOK_SECRET=...STRIPE_TOLERANCE_SECONDS=... (only in stripe mode)Headers:
X-Signature-Timestamp: <unix>X-Signature: <hex(hmac_sha256("<ts>.<raw_body>", WEBHOOK_SECRET))>Verification (PHP pseudo):
$timestamp = $_SERVER['HTTP_X_SIGNATURE_TIMESTAMP'];
$signature = $_SERVER['HTTP_X_SIGNATURE'];
$payload = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $payload, $secret);
hash_equals($expected, $signature);
Header:
Stripe-Signature: t=<ts>,v1=<hex(hmac_sha256("<ts>.<raw_body>", WEBHOOK_SECRET))>
Sandbox sends exactly one v1 signature.
Verification (PHP pseudo):
[$tPart, $v1Part] = explode(',', $_SERVER['HTTP_STRIPE_SIGNATURE']);
$timestamp = explode('=', $tPart)[1];
$signature = explode('=', $v1Part)[1];
$payload = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $payload, $secret);
hash_equals($expected, $signature);
In WEBHOOK_SIGNING_MODE=stripe, reject if:
abs(now - t) > STRIPE_TOLERANCE_SECONDSpayment.updatedPayload:
{
"event": "payment.updated",
"data": {
"transaction_id": "tx_xxx",
"merchant_order_id": "ORD-12345",
"status": "APPROVED|DECLINED|3DS_REQUIRED|CAPTURED|REFUNDED",
"amount": 150.75,
"currency": "EGP",
"token": "tok_xxx_if_any",
"provider_ref": "psp_xxx"
}
}
POST /psp/payments/authorize with scenario=3dsstatus=3DS_REQUIRED and acs_urlacs_url (mock challenge UI)POST /psp/3ds/complete/{transaction_id} with result=success|failAPPROVED or DECLINED and sends webhook payment.updatedreturn_url was provided, the sandbox redirects the browser to:return_url?transaction_id=...&status=...&token=... (token only if approved)
Purpose: Service health check.
GET /health200{
"ok": true,
"service": "bank-sandbox-api",
"time": "2026-01-26T10:00:00Z"
}
Purpose: Create a transaction and return APPROVED, DECLINED, or 3DS_REQUIRED (or simulated timeout / error).
Path: POST /psp/payments/authorize
Headers:
Content-Type: application/jsonIdempotency-Key: <string> (optional)X-Api-Key: ... (required if SANDBOX_AUTH_MODE=api_key)Body:
{
"merchant_order_id": "ORD-12345",
"amount": 150.75,
"currency": "EGP",
"encrypted_card_payload": "BASE64_OR_HEX_STRING",
"scenario": "approve|decline|3ds|timeout|error",
"webhook_url": "https://your-system.com/webhooks/payment",
"return_url": "https://your-system.com/payments/return"
}
merchant_order_id: required, string, max 100amount: required, numeric, > 0, max 2 decimalscurrency: required, 3-letter uppercase (e.g. EGP)encrypted_card_payload: required string, min length 20scenario: optional, one of approve|decline|3ds|timeout|errorwebhook_url: optional, valid URLreturn_url: optional, valid URL200 (APPROVED){
"transaction_id": "tx_xxx",
"merchant_order_id": "ORD-12345",
"status": "APPROVED",
"amount": 150.75,
"currency": "EGP",
"token": "tok_xxx",
"provider_ref": "psp_xxx",
"created_at": "2026-01-26T10:00:00Z"
}
200 (DECLINED){
"transaction_id": "tx_xxx",
"merchant_order_id": "ORD-12345",
"status": "DECLINED",
"decline_code": "DO_NOT_HONOR",
"decline_message": "Payment was declined",
"amount": 150.75,
"currency": "EGP",
"created_at": "2026-01-26T10:00:00Z"
}
200 (3DS_REQUIRED){
"transaction_id": "tx_xxx",
"merchant_order_id": "ORD-12345",
"status": "3DS_REQUIRED",
"acs_url": "https://banksandboxapi.shake-out.com/api/psp/3ds/challenge/tx_xxx",
"amount": 150.75,
"currency": "EGP",
"created_at": "2026-01-26T10:00:00Z"
}
504 (scenario=timeout){
"error": "GATEWAY_TIMEOUT",
"message": "Simulated timeout",
"details": {
"transaction_id": "tx_xxx_optional"
}
}
500 (scenario=error){
"error": "PROVIDER_ERROR",
"message": "Simulated provider error",
"details": {
"provider_error_code": "SIMULATED_500"
}
}
401 UNAUTHORIZED: API key missing/invalid422 UNPROCESSABLE_ENTITY: validation failed or idempotency conflict504 GATEWAY_TIMEOUT: scenario timeout500 INTERNAL_SERVER_ERROR: scenario errorPurpose: Capture an approved payment.
Path: POST /psp/payments/capture
Headers:
Content-Type: application/jsonX-Api-Key: ... (required if SANDBOX_AUTH_MODE=api_key)Body:
{
"transaction_id": "tx_xxx"
}
200{
"transaction_id": "tx_xxx",
"merchant_order_id": "ORD-12345",
"status": "CAPTURED",
"amount": 150.75,
"currency": "EGP",
"token": "tok_xxx",
"provider_ref": "psp_xxx",
"created_at": "2026-01-26T10:00:00Z"
}
401 UNAUTHORIZED404 NOT_FOUND: transaction not found422 UNPROCESSABLE_ENTITY: allowed only when status is APPROVEDPurpose: Refund a captured payment.
Path: POST /psp/payments/refund
Headers:
Content-Type: application/jsonX-Api-Key: ... (required if SANDBOX_AUTH_MODE=api_key)Body:
{
"transaction_id": "tx_xxx"
}
200{
"transaction_id": "tx_xxx",
"merchant_order_id": "ORD-12345",
"status": "REFUNDED",
"amount": 150.75,
"currency": "EGP",
"token": "tok_xxx",
"provider_ref": "psp_xxx",
"created_at": "2026-01-26T10:00:00Z"
}
401 UNAUTHORIZED404 NOT_FOUND: transaction not found422 UNPROCESSABLE_ENTITY: allowed only when status is CAPTUREDPurpose: Display mock 3DS challenge page (HTML).
GET /psp/3ds/challenge/{transaction_id}200 text/html: page with Approve / Fail buttons404 NOT_FOUNDPurpose: Complete 3DS challenge and update transaction state.
Path: POST /psp/3ds/complete/{transaction_id}
Headers:
Content-Type: application/json or application/x-www-form-urlencodedBody (either):
{ "result": "success|fail" }result=success|fail200 (JSON){
"transaction_id": "tx_xxx",
"status": "APPROVED|DECLINED",
"token": "tok_xxx_if_any"
}
return_url is set)Returns 302 redirect to return_url with query params:
transaction_idstatustoken (only if approved)This endpoint is excluded from CSRF protection because it is called from the mock 3DS page.
404 NOT_FOUND422 UNPROCESSABLE_ENTITY: invalid state or invalid resultPurpose: Debug endpoint to view safe transaction data.
Path: GET /psp/transactions/{transaction_id}
Headers:
X-Api-Key: ... (required if SANDBOX_AUTH_MODE=api_key)200{
"transaction_id": "tx_xxx",
"merchant_order_id": "ORD-12345",
"amount": 150.75,
"currency": "EGP",
"status": "APPROVED",
"scenario": "approve",
"token": "tok_xxx",
"provider_ref": "psp_xxx",
"webhook_url": "https://your-system.com/webhooks/payment",
"return_url": "https://your-system.com/payments/return",
"created_at": "2026-01-26T10:00:00Z",
"updated_at": "2026-01-26T10:00:00Z"
}
401 UNAUTHORIZED404 NOT_FOUND