Skip to content

Quickstart

This takes a mobile money collection from nothing to a completed payment in the sandbox. No real money moves. Everything here works the same way against api.paysigna.com when you are ready.

You need a test secret key. Get one from the merchant portal at app.paysigna.com under Developers → API keys, in the Test environment. It looks like psg_test_7hQ2mKpR9fL3xW8v_….

Terminal window
curl https://sandbox.paysigna.com/v1/balances \
-H "Authorization: Bearer psg_test_7hQ2mKpR9fL3xW8v_…"

A 200 with your balances means the key is good. A 401 means the key is wrong or revoked; a 403 with environment_mismatch means you are holding a live key against the sandbox host, or the reverse.

Mobile money charges need a momo_network_id. It is a UUID, not a name, so resolve it once and cache it rather than hardcoding it:

Terminal window
curl https://sandbox.paysigna.com/v1/reference/momo-networks \
-H "Authorization: Bearer psg_test_…"

There are matching reference lists for banks, currencies and countries.

The Idempotency-Key is required on this call. Generate a fresh UUID per payment attempt and keep it — if this request times out, you resend it with the same key and get the original answer rather than a second charge.

Terminal window
curl https://sandbox.paysigna.com/v1/charges \
-H "Authorization: Bearer psg_test_…" \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Content-Type: application/json" \
-d '{
"amount": 15000,
"currency": "GHS",
"channel": "mobile_money",
"momo_network_id": "1f0a9c2e-5b31-4d8e-9a77-0c1b2d3e4f50",
"customer": {
"identifier": "0541234567",
"name": "Ama Mensah"
},
"reference": "order-1043",
"description": "Order 1043"
}'

amount is minor units: 15000 is GHS 150.00. There is no decimal form of an amount anywhere in this API.

Put whatever amount and whatever mobile number you like in it. The sandbox has no amount ceiling and no list of approved test numbers or cards, so you can rehearse with the figures your product actually charges. To make this charge fail instead, add "metadata": { "sandbox_outcome": "insufficient_funds" } — the full list is in Sandbox and testing.

The response:

{
"id": "txn_01JB3K2N8QZX4V7M9P0R5T6W8Y",
"object": "charge",
"status": "awaiting_action",
"reference": "PSG-4K2N8QZX",
"your_reference": "order-1043",
"amount": 15000,
"currency": "GHS",
"customer_amount": 15000,
"merchant_amount": 14700,
"fee": { "platform": 300, "tax": 0, "bearer": "merchant" },
"channel": "mobile_money",
"customer": { "masked": "054****567", "name": "Ama Mensah" },
"action": { "type": "prompt" },
"created_at": "2026-10-05T14:21:03Z",
"expires_at": "2026-10-05T14:36:03Z"
}

Three things to read carefully:

  • status is awaiting_action, and that is success. The customer has a prompt on their handset. Most charges return pending or awaiting_action and complete asynchronously. Do not treat the absence of succeeded as a failure — this is the single most common first-integration bug.
  • action.type tells you what the customer has to do. prompt means it is on their phone and you wait. 3ds_redirect or hosted_checkout come with a url you send their browser to.
  • merchant_amount is what lands in your balance — amount minus the fee. Reconcile against this, not against amount.

Store the id against your order now, before you do anything else. It is how every later message about this payment identifies itself.

The status change arrives as a webhook. Register the URL once — this is setup, not per-payment:

Terminal window
curl https://sandbox.paysigna.com/v1/webhook-endpoints \
-H "Authorization: Bearer psg_test_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/paysigna/webhooks",
"subscribed_events": ["transaction.succeeded", "transaction.failed", "transaction.expired"],
"description": "Order status"
}'

Subscribe explicitly, as above. An empty subscribed_events means every event type, which costs you deliveries and us retries for events you ignore.

The URL must be HTTPS and publicly routable — loopback and private addresses are refused, because our delivery workers run inside our own network. For local development use a tunnel.

When the customer approves, you get a POST to that URL. Verify the signature before you trust a byte of it, then enqueue and return quickly — we count a response slower than your timeout_seconds (default 10) as a failed attempt.

{
"id": "evt_01JB3K2P4RT7YV9X1Z3B5D7F9H",
"object": "event",
"type": "transaction.succeeded",
"resource_type": "transaction",
"resource_id": "txn_01JB3K2N8QZX4V7M9P0R5T6W8Y",
"sequence": 48213,
"created_at": "2026-10-05T14:22:41Z",
"data": { "...": "the transaction, as in step 3" }
}

Deduplicate on id — delivery is at-least-once by design. Record the highest sequence you have processed; a gap in it means a lost event, and you can ask for exactly the missing range later.

Webhooks has the verification code and the full argument for why it is shaped the way it is. Do not skip it.

Terminal window
curl https://sandbox.paysigna.com/v1/charges/txn_01JB3K2N8QZX4V7M9P0R5T6W8Y \
-H "Authorization: Bearer psg_test_…"

Use this to answer a question about a specific payment — a customer asking, a webhook you think you missed, a reconciliation run. Do not poll every open transaction on a timer: it burns your rate limit and still tells you later than the webhook does.

Create, receive, read. A payout is the same three steps pointed the other way (POST /v1/payouts), and a refund is POST /v1/refunds against a charge.

Next:

  • Authentication — scopes, key types, and what to do about rotation before it surprises you.
  • Idempotency — the rules, and what a retry actually does.
  • Errors — the codes worth branching on.
  • Going live — what has to be true before a psg_live_… key works.