Testing
Sandbox keys, processor test cards, and how to provoke every refusal on purpose before your users find them.
Sandbox keys
A key carries its environment in the token itself: sk_sandbox_... versus sk_live_.... Sandbox keys move no real money, and a sandbox key pointed at production simply fails to authenticate rather than doing something expensive.
Use sandbox for development, CI, and staging. The API surface is identical; only the money is not real.
Card top-ups
Money in runs through the payment processor's test mode. Confirm with a test card and the whole flow works end to end, including the signed webhook.
| Card | Behaviour |
|---|---|
4242 4242 4242 4242 | Succeeds |
4000 0000 0000 0002 | Declined |
4000 0000 0000 9995 | Declined for insufficient funds |
4000 0025 0000 3155 | Requires 3D Secure authentication |
Any future expiry and any CVC. Server-side, confirm with the processor's test payment method (pm_card_visa) and skip the browser entirely, which is what the platform's own gate scripts do.
Test-mode top-ups produce genuine topup.succeeded webhooks with real signatures, so your verification code is exercised for real.
Provoking each failure
The paths worth testing are the refusals, not the happy one.
| To test | Do this | Expect |
|---|---|---|
| Insufficient funds | Transfer more than available | 422 insufficient_funds |
| Tier limit | Set max_balance low, then top up past it | 422 tier_limit_exceeded, and no processor intent |
| Idempotent replay | Send the same request twice with one key | Identical response, one movement |
| Key misuse | Same key, different amount | 409 idempotency_key_reuse |
| Scope | Call with a token lacking the scope | 403 insufficient_scope |
| Cross-user | User token acting on another user | 403 forbidden |
| Cross-tenant | Read another tenant's object id | 404, not 403 |
| Rate limit | Exceed an endpoint budget | 429 with Retry-After |
| Refund cap | Refund more than captured, cumulatively | 400 invalid_request |
| Hold expiry | Authorize with hold_ttl_seconds: 60, wait | payment.expired, hold released |
| Bad webhook signature | Flip one byte of a delivery body | Your receiver returns 401 |
That last one is a test of your code, and it is the one people skip. A receiver that accepts a tampered body is the failure this whole model exists to prevent, so assert on it.
Testing idempotency properly
Sending the same request twice is only half the test. The real property is that a timeout is safe:
def test_replay_is_byte_identical(client, sender, recipient):
key = f"test-transfer-{uuid.uuid4()}"
body = {"from_user": sender, "to_user": recipient, "amount": 1500}
first = client.request("POST", "/v1/transfers", body, idempotency_key=key)
second = client.request("POST", "/v1/transfers", body, idempotency_key=key)
assert first == second # same response, byte for byte
assert balance(client, sender) == before - 1510 # money moved onceAssert on the balance, not just on the response. A replay that returns the right JSON while moving money twice would pass a weaker test.
Running the platform locally
The whole stack runs on one machine with Docker: the API, the gateway, Postgres, the identity provider, and a webhook receiver that verifies signatures the way yours should.
make up # build and start everything
make demo # provision tenants and run scripted scenarios with exact-balance assertionsThe demo is a full integration test wearing a demo costume: it provisions through the real APIs and asserts exact balances at every step, so a green run means the money paths behave as documented. Green output is also the fastest way to see request and response shapes for a flow you are about to build.
Local top-ups use the processor's test mode, so you need test keys in your environment. Without a processor account, an offline mock rail can be enabled for development, and GET /v1/topup_methods will show it in place of the card rail. Your integration code does not change either way, because it reads the rail rather than naming one.
In CI
Three things are worth wiring into your own pipeline:
- Idempotent replay of every money-moving call you make.
- Signature verification, both the accept and the reject case.
- Error handling for
422and429specifically, since those are the ones that reach real users.
Point your tests at sandbox, use a fresh external_id prefix per run so cases do not collide, and assert balances rather than status codes wherever money is involved.