Payment links and hosted checkout
Two ways to take a payment without building a payment form:
| What it is | When to use it | |
|---|---|---|
| Hosted checkout | A page on pay.paysigna.com for one payment session you created. |
Your site knows the order and the amount, and wants us to collect it. |
| Payment link | A reusable address on pay.paysigna.com with its own rules. |
No order on your side — dues, a ticket, a donation, an invoice paid in instalments. |
Both pages carry your checkout branding and show “Powered by PaySigna”.
Hosted checkout
Section titled “Hosted checkout”POST /v1/payment_sessions now returns a url:
https://pay.paysigna.com/s/<session id>#<client token>Send the customer’s browser there. Like the client token, url is returned
once, on creation. The token sits after the #, so browsers never send it to any
server. The page moves it into the tab’s session storage and removes it from the
address bar.
The page offers the channels the session allows that it can take — mobile money
(the payer chooses the network and types the number, then approves the prompt
while the page waits), bank (the payer chooses their bank and types the
account number) and card (the payer is sent to the card partner’s page and
comes back). If the partner doesn’t answer in time, the page doesn’t show an error or let the
payer try again, which would invite a second payment. It shows the normal
waiting view and keeps checking with the partner. The card page is opened once. When the partner sends the payer
back (the page recognises the parameters each partner adds to the return address,
such as Paystack’s trxref), the page shows “Confirming your card payment…” and checks with the
partner, instead of sending them to the card page again. If they left the card
page early, it offers “open it again”. Where a bank payment, or a mobile-money payment on some partners (a Telecel
voucher, for example), needs a one-time code, the page asks for it,
showing the partner’s message, when the code expires, the attempts left, and
Send a new code when the code can be resent. Payment links do not take bank, so
the bank option appears only on a payment session that allows it. It then shows the result with the payment’s reference:
- the thank-you message is a link’s own, then your branding’s, then ours;
- after a successful payment the page goes to a link’s
redirect_url, or the session’sreturn_url, five seconds later.
The fee, before the payer pays
Section titled “The fee, before the payer pays”When the payer bears the fee, the page shows Amount, Fee and Total as soon as they’ve chosen a method (and, on a link where they choose, the amount). The button reads Pay {total}. The figures come from the same pricing as the charge, so the total is what the payer is asked to approve. Pay is enabled only once the total is known; if it can’t be worked out, the page says so and Pay stays disabled. A pricing refusal, such as no rate for that method, shows the reason. A network error or server error is retried every two seconds. After three failures the page says “We could not work out the total to pay. Check your connection and reload the page.” When the merchant bears the fee, the page shows the amount alone.
If the gateway doesn’t have the fee preview yet (a pay app deployed ahead of it), the page shows no breakdown and Pay works as before.
The mobile-money waiting text names the amount actually charged. A fee a partner adds on the payer’s phone (PaySwitch’s surcharge, where its account is set that way) isn’t included, because it’s outside our price.
While the payment is unconfirmed
Section titled “While the payment is unconfirmed”After Pay, the page checks the payment every three seconds. Once a payment exists, each check asks the partner as Check payment status does, at most once every 10 seconds per payment. So an approval shows within seconds, even when the partner sends no callback. Once a payment has expired, or after a failed read, the page checks every 30 seconds instead, and keeps going until a read works.
| What the payment is | What the payer sees |
|---|---|
| In flight, under two minutes | The waiting view, and Check payment status. For mobile money, “Approve the payment on your phone” appears the moment Pay is pressed, not once the partner has accepted the request. For card, “Taking you to the secure card page…”. |
| In flight, past two minutes | The same, plus “Still waiting for your network to confirm. If you have approved it, you can close this page”, saying you will be told when the payment arrives, and the reference. |
expired |
“We have not had confirmation yet.” “If you approved the payment and money left your account, do not pay again.” The page keeps checking every 30 seconds and updates by itself. It shows the reference and Check payment status, and offers Start again under “Only start again if nothing was taken from you.” |
failed, cancelled, reversed |
“The payment did not go through”, with the failure reason, or “Nothing was taken from you.” when there is none. |
An expired collection is not shown as a failure because it can still succeed: the partner may confirm the approval late. See Webhooks.
Check payment status asks the partner about the payment straight away, then shows the result. It uses the same checks as that scheduled one, so it can settle a late success on an expired collection. The partner is asked at most once every 10 seconds per payment (per server if our cache is unavailable), and the button is disabled for 10 seconds after each press.
With a test key the page shows a banner — “Test mode: no real money moves.” Test
and live links and sessions share the one address, pay.paysigna.com; the page
finds the right environment by itself.
The payment itself is a normal charge: build on the collection.*
webhooks, not on the customer coming back.
One-time codes on your own checkout page
Section titled “One-time codes on your own checkout page”If you confirm a payment session from your own page rather than sending the
customer to ours, a bank payment may come back with payment.action.type otp.
The browser sends the customer’s code straight to us:
| Endpoint | Body |
|---|---|
POST /v1/checkout/sessions/{id}/otp |
{ "code": "123456" } |
POST /v1/checkout/sessions/{id}/otp/resend |
None. Only when payment.action.resendable is true; otherwise 422 unsupported. Attempts already used still count. |
Both take your publishable key with the payment_session.confirm scope and the
session’s client token in X-Paysigna-Client-Token, the same as confirming the
session. No Idempotency-Key is needed: if two submissions arrive at once, only one
reaches the partner, and the other gets 409 request_in_progress. Both answer with
the session, as GET /v1/checkout/sessions/{id} returns it.
payment.action carries message (show it as it is: it says where the code is),
expires_at, resendable and attempts_remaining. Keep asking while
payment.action.type is otp: a first payment from an Ecobank account asks for an
emailed enrolment code, then a code from the customer’s authenticator app. A wrong
code is 422 otp_rejected and the payment keeps waiting; after the last attempt it
fails with otp_attempts_exceeded. The errors are the same as on the server-side
POST /v1/charges/{id}/otp.
Payment links
Section titled “Payment links”Secret key only. billing.read to read, billing.write to create or change.
curl https://sandbox.paysigna.com/v1/payment-links \ -H "Authorization: Bearer psg_test_…" \ -H "Content-Type: application/json" \ -d '{ "title": "2026 annual dues", "amount_mode": "fixed", "amount": 25000, "currency": "GHS", "channels": ["mobile_money", "card"], "collect_phone": "required", "custom_fields": [{ "key": "member_no", "label": "Membership number", "required": true }] }'The response carries url — https://pay.paysigna.com/l/<slug> — to share. Its QR
code is the same address.
| Field | Notes |
|---|---|
title |
Required, 2 to 150 characters. Shown at the top of the page. description goes under it. |
currency |
Defaults to your settlement currency. |
amount_mode |
fixed (with amount) or open — the payer types the amount, within optional min_amount and max_amount. |
payment_mode |
full (default): each payment pays in full. payer_parts: payers pay any part, at least min_part, until amount is collected; the last part may be smaller. instalments: amount split into instalment_count equal parts (2 to 60), the remainder on the last, paid in order. |
max_uses |
full only. Omit for unlimited; 1 makes a one-time link. A link paid in parts ends when its total is reached instead. |
starts_at, expires_at |
Optional. Not payable before or from these. |
channels |
At least one of mobile_money and card. Links do not take bank; it is refused with invalid_field. With a live key, a channel your account is not enabled to collect over is refused with 403 capability_disabled: “This account is not enabled to collect over {method}.”, followed by the reason. A channel that is enabled but not yet priced is accepted; the payment page leaves it out until a price applies. |
collect_name, collect_email, collect_phone |
required, optional or hidden. Defaults: required, optional, hidden. A card payment always asks for an email. |
custom_fields |
Up to three questions: a label (up to 80 characters), a key (defaults to q1, q2…) and required. |
success_message, redirect_url |
Shown after paying, and an https:// address to send the payer to afterwards. |
The currency, amount mode and payment mode cannot be changed after the link is created. The API has no edit yet; the portal does — Payment links. All fields are in the reference.
Where a link stands
Section titled “Where a link stands”status is what you set: active, paused, completed or archived. Change it
with POST /v1/payment-links/{id}/status and active, paused or archived;
archiving cannot be undone.
availability says whether a payer could pay it right now:
availability |
Meaning |
|---|---|
open |
Taking payments. |
paused, archived |
As set. |
not_started, expired |
Before starts_at, or from expires_at. |
completed |
Its total was reached, every instalment was paid, or its last use was taken. |
sold_out |
Every use, part or instalment is paid or in flight. |
The payment page also checks your account: a live link takes payments only while
your account is active and live-enabled, and a test link while it is active or
still being set up. Otherwise the page shows the link as not taking payments,
whatever availability says.
On a live link, the page also offers only the channels that can take a payment
today: enabled for your live account, priced, and carried by a payment partner. The
others are left out without an error. If none remain, the page tells the payer
“This link cannot take payments right now. Please contact the merchant.” Test
links offer every channel they were saved with. The portal shows which methods
your account can use; see
Which methods you can use.
“In flight” is read from the payment itself, so a payment that fails or expires
frees its use, part or instalment straight away — sold_out can turn back into
open.
A late success always counts. An expired payment can still succeed if the
partner confirms it later; see Webhooks. By then
someone else may have taken the freed use. The late payment is settled anyway,
because the customer was debited. So a link can end up paid more times than
max_uses, or collect more than its total. On an instalment link, a late payment
whose instalment has since been paid counts as the earliest unpaid instalment. If
every instalment is paid, it counts as an extra payment with no instalment number.
Refund it if you don’t want it.
A link also reports paid_count and collected, and for parts and instalments
remaining (net of payments in flight), next_instalment and
next_instalment_amount.
Payments through a link
Section titled “Payments through a link”GET /v1/payment-links/{id}/payments lists them: amount, instalment, status
(pending, succeeded, failed or expired), the payer’s details, answers by
key and the transaction reference.
Each one is an ordinary charge. It appears in your transactions, fires the
usual transaction.* webhooks, settles and refunds like any other. Its metadata
carries payment_link (the link’s slug) and each answer as answer_<key>. The
charge names whoever created the link: the API key, or the portal user.

