Skip to content

Sandbox and testing

https://sandbox.paysigna.com

The sandbox is the gateway, not a mock. Same binaries, same database schema, same risk rules, same ledger, same settlement batches, same signed webhook delivery. Exactly one thing is substituted: the rail at the far end that would have moved the money.

Your sandbox activity is kept strictly apart from your live activity throughout — a test payment is delivered only to your test webhook endpoints, accrues only into test settlement batches, and is counted only in test figures.

That matters because the alternative — a hand-written mock — diverges from the real thing exactly where it is least convenient to find out. A flow that works here works live.

Sandbox Live
Host sandbox.paysigna.com api.paysigna.com
Keys psg_test_… psg_live_…
Rail A simulator we run Real partners
Amount limits None Your limits, and the partners’
Instruments Any number, account or card Real ones
Money None Real
KYC required No Yes — see Going live
Webhooks Delivered normally, signed normally, to your test endpoints Same, to your live ones
Emails and SMS Prefixed [TEST] Not prefixed

Keys are not interchangeable. A psg_test_… key against api.paysigna.com returns 403 with code environment_mismatch, and so does the reverse. The environment is baked into the key string so you can catch that by looking.

There are no magic numbers and no amount ceiling

Section titled “There are no magic numbers and no amount ceiling”

Send any amount. One pesewa or ten million cedis; the sandbox has no band of its own. If your checkout sells a GHS 4,000 item, test it with GHS 4,000.

Use any instrument. Any mobile number on any network, any bank account in any format, any card number. There is no list of approved test cards to look up and nothing to memorise.

This is deliberate and it is a change from how most gateways in this market work. A vendor’s own test environment caps the amount and authorises four specific PANs, which means the one thing you cannot test there is your own product. We run the sandbox rail ourselves so that none of those restrictions reach you.

Two things the sandbox still enforces, because they are not restrictions:

  • The amount must be positive. A zero or negative charge is not a small payment; it is a request the rest of the arithmetic has no meaning for.
  • A payout cannot exceed your test balance. The ledger is real, and a sandbox that allowed an overdraft would be teaching your integration that overdrafts exist. Collect first — for any amount you like — and pay out of that.

By default a sandbox transaction succeeds. To get anything else, set the sandbox_outcome key in the request’s metadata:

{
"amount": 400000,
"currency": "GHS",
"channel": "mobile_money",
"customer": { "identifier": "0244000000", "name": "Ama Mensah" },
"metadata": { "sandbox_outcome": "insufficient_funds" }
}
sandbox_outcome What happens failure_class
absent, or succeed Accepted, then succeeds a moment later —
pending Stays in flight for ten minutes, then times out timeout
indeterminate The create call fails with partner_timeout and the charge is left unresolved set at expiry
insufficient_funds Fails immediately insufficient_funds
invalid_recipient Fails immediately invalid_recipient
limit_exceeded Fails immediately limit_exceeded
declined Fails immediately declined
timeout Fails immediately timeout
transport Fails immediately transport
partner_unavailable Fails immediately partner_unavailable
duplicate Fails immediately duplicate

Metadata rather than a magic amount or a magic card number, so that choosing an outcome never costs you the ability to send the amount you actually wanted to test. It works the same on a collection and on a payout.

failure_class is the value you will see on the transaction, in the webhook and in the error body — the same vocabulary live uses, not a sandbox dialect.

An unrecognised value succeeds. A typo should not be the reason you spend an afternoon on a failure you did not ask for.

A sandbox collection is accepted immediately and settles about three seconds later, because a real mobile-money charge is asynchronous and the sandbox does not pretend otherwise. The sequence is the one live uses:

  1. POST /v1/charges returns awaiting_action (mobile money) or pending.
  2. A moment later the transaction completes, the ledger posts, and your balance moves.
  3. Your webhook endpoint is called, signed.

So the create response is not where you learn the outcome — in the sandbox or in live. Build on the webhook from the start; it is the only ordering that is correct in production.

All three channels work, for collections and payouts.

Mobile money returns an awaiting_action status with an action type of prompt and no action URL, which is exactly what live returns: on a real rail the approval message is already on the customer’s handset and there is nothing for you to render. Card returns pending with no redirect, because a fake 3-D Secure page would teach your integration to send customers to a URL we do not issue in live.

The API reference has a request console built in. Open an operation, choose Test Request, put a test secret key in the Authentication panel, and send it. The response you see is the real response from sandbox.paysigna.com.

Three things worth knowing about it:

It only ever targets the sandbox. The live host is not in the server list, and not because it is hidden in the interface — it is absent from the OpenAPI document this page loads, so there is nothing to select. The reasoning is in Going live; the short version is that a request console is a browser, and a secret key in a browser is the thing publishable keys exist to prevent.

Your key is not stored. The console does not persist what you type into the authentication panel, so you will paste the key again next visit. That is deliberate: persisting it would mean a payment credential sitting in this site’s web storage, readable by any script that ever got injected into this page. One paste per session is the cheaper side of that trade.

Use a test key anyway. Even against the sandbox, treat the key in your clipboard as a credential. Do not paste a live key into this page; it will not work, and you will have put it somewhere it did not need to go.

A browser will not let this page read a response from another origin unless that origin says it may. If sandbox.paysigna.com does not list https://docs.paysigna.com in its allowed origins, every request from the console fails — and it fails in a way that looks nothing like the real cause.

What you see is a request that never returns a status, or a generic “Failed to fetch” / “Network error”, with a CORS message in the browser console. What it is not is a bad key, a wrong body or a down API. The giveaway is that the same curl works perfectly from your terminal, because curl is not a browser and never asked permission.

If you hit that, the sandbox deployment needs this site’s origin added to HTTP_CORS_ORIGINS — that is our configuration to fix, not yours. Tell us at [email protected] and use curl or your own code in the meantime.

Testing the parts that are hard to trigger

Section titled “Testing the parts that are hard to trigger”

Webhook deliveries. You do not need to make a payment succeed to test your handler. POST /v1/webhook-deliveries/{id}/replay re-sends a delivery you have already had, signed freshly, so you can iterate on your verification code against a real signature.

Backfilling after an outage. GET /v1/events returns everything we tried to send you, with data byte-for-byte as it was signed — so a stored signature still verifies against it. Reading it sends nothing, which means you can recover without your handler being hit by a burst of replays. Pass after_sequence to walk oldest-first from the last sequence you processed; that is the only order in which applying missed events is correct.

Failure paths. Rail failures come from sandbox_outcome above. The rest of the error codes in Errors are provoked by malforming the request on purpose: reuse an Idempotency-Key with a different body for idempotency_key_reused, present a key without the scope for insufficient_scope. Build the branches before you need them live.

Emails and SMS we send. Every notification from the sandbox is prefixed [TEST] in its subject line — or at the front of the message, for an SMS — and carries a TEST MODE badge in the body. A sandbox dispute notice forwarded to somebody’s finance team should be obvious at a glance from the inbox list, not only after it has been opened. If you ever receive an unprefixed message from a test key, tell us: that is a bug on our side, not a quirk.

Local webhook development. Your endpoint URL must be HTTPS and publicly routable — loopback, private and link-local addresses are refused, because our delivery workers run inside our own network and could not reach them anyway. Use a tunnel for local work, and register the tunnel URL as a test-environment endpoint.

Your integration is sandbox-complete when all four of these are true:

  1. A successful collection updates your own records from the webhook, not from the create response.
  2. A retried create with the same Idempotency-Key does not produce a second payment.
  3. Your webhook handler verifies the signature, deduplicates on event id, and returns inside its timeout.
  4. You reconcile against merchant_amount, not amount.

Then read Going live.