Skip to content

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”.

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’s return_url, five seconds later.

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.

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.

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.

Secret key only. billing.read to read, billing.write to create or change.

Terminal window
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.

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.

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.