WalletD developers

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.

CardBehaviour
4242 4242 4242 4242Succeeds
4000 0000 0000 0002Declined
4000 0000 0000 9995Declined for insufficient funds
4000 0025 0000 3155Requires 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 testDo thisExpect
Insufficient fundsTransfer more than available422 insufficient_funds
Tier limitSet max_balance low, then top up past it422 tier_limit_exceeded, and no processor intent
Idempotent replaySend the same request twice with one keyIdentical response, one movement
Key misuseSame key, different amount409 idempotency_key_reuse
ScopeCall with a token lacking the scope403 insufficient_scope
Cross-userUser token acting on another user403 forbidden
Cross-tenantRead another tenant's object id404, not 403
Rate limitExceed an endpoint budget429 with Retry-After
Refund capRefund more than captured, cumulatively400 invalid_request
Hold expiryAuthorize with hold_ttl_seconds: 60, waitpayment.expired, hold released
Bad webhook signatureFlip one byte of a delivery bodyYour 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 once

Assert 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 assertions

The 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:

  1. Idempotent replay of every money-moving call you make.
  2. Signature verification, both the accept and the reject case.
  3. Error handling for 422 and 429 specifically, 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.

Next

On this page