Transactions
Collect a one-time payment through hosted checkout or a direct bank-transfer charge.
A transaction is a one-time collection. Your backend creates it, the customer pays, and you verify the result before giving value.
There are two ways to create one:
| Initialize | Charge | |
|---|---|---|
| Endpoint | POST /v1/transactions/initialize | POST /v1/transactions/charge |
| Customer pays via | A hosted checkout page | A bank transfer |
| You get back | A checkout URL | Payment instructions |
| Use when | You want Kyshi to handle the payment UI | You want to present bank details in your own UI |
| Use instead | If |
|---|---|
| Virtual Accounts | You want a reusable account, or full control of the bank-transfer experience. |
| Payment Links | You want to send a link rather than redirect from your own flow. |
| Subscriptions | The payment repeats on a schedule. |
The Flow
sequenceDiagram
participant C as Customer
participant Y as Your backend
participant K as Kyshi
Y->>K: POST /v1/transactions/initialize
K-->>Y: authorizationUrl (status PENDING)
Y->>C: Redirect to checkout
C->>K: Completes payment
K-->>Y: Webhook charge.success
Y->>K: GET /v1/transactions/verify/{reference}
K-->>Y: status
Y->>C: Fulfil (only if SUCCESS)
The response to step 1 is not a payment. It is a PENDING transaction and a URL. Nothing has been collected yet.
Build It
1. Create the transaction
-H "x-api-key: your_secret_key"POST {{host}}/v1/transactions/initialize{
"email": "[email protected]",
"amount": 1000,
"localCurrency": "NGN",
"amountCurrency": "local",
"reference": "ORDER-10001",
"redirectUrl": "https://merchant.example.com/payments/callback",
"metadata": { "orderId": "10001" }
}const checkout = await kyshi.transactions.initialize({
email: '[email protected]',
amount: 1000,
localCurrency: 'NGN',
amountCurrency: 'local',
reference: 'ORDER-10001',
redirectUrl: 'https://merchant.example.com/payments/callback',
});Always send your own reference. It is how you verify, reconcile, and recognise duplicate webhooks.
Be deliberate about amountCurrency. With local, amount is the exact amount the customer pays in localCurrency. With settlement — the default — amount is in your settlement currency and Kyshi converts. Sending 1000 meaning Naira while the default treats it as settlement currency will charge roughly 1500× too much. See Countries And Currencies.
2. Send the customer to checkout
Redirect to the authorizationUrl from the response.
redirectUrl is where the customer lands afterwards. Treat that return as a UI event only — the customer arriving on your success page is not proof of payment. They can close the tab, or edit the URL.
3. Wait for the webhook
Kyshi sends charge.success or charge.failed. See Webhook Events.
4. Verify, then fulfil
GET {{host}}/v1/transactions/verify/ORDER-10001const transaction = await kyshi.transactions.verify('ORDER-10001');
if (transaction.status === 'SUCCESS') await fulfilOnce('ORDER-10001');This is the only signal you should release goods on.
Collecting By Bank Transfer Instead
POST /v1/transactions/charge with chargeType: "BANK_TRANSFER" returns bank details for the customer to pay into, rather than a checkout URL. The request fields are otherwise the same.
{
"email": "[email protected]",
"amount": 5000,
"localCurrency": "NGN",
"amountCurrency": "local",
"reference": "ORDER-10002",
"chargeType": "BANK_TRANSFER"
}The customer types the amount themselves, so the same underpayment and overpayment rules as Virtual Accounts apply. Read that guide's failure section before shipping a bank-transfer flow.
Request Fields
| Field | Required | Notes |
|---|---|---|
email | Yes | Customer email. |
amount | Yes | Major units. Minimum 1. |
localCurrency | Yes | NGN, KES, ZAR, GHS, XOF, USD, subject to your integrations. |
amountCurrency | No | settlement (default) or local. |
reference | No | Yours. Generated if omitted. |
redirectUrl | No | Where the customer returns after checkout. |
channels | No | Restrict payment methods, e.g. ["card"]. |
metadata | No | Attached to the transaction and returned on verification. |
feeBearer | No | MERCHANT or CUSTOMER. |
taxChargeable | No | INCLUSIVE or EXCLUSIVE. |
Full schemas on Initialize Transaction and Charge Transaction.
Statuses
| Status | Fulfil? |
|---|---|
PENDING | No — not finished |
SUCCESS | Yes, once |
FAILED | No |
IN_REVIEW | No — resolves on Kyshi's side |
REVERSAL | No — reclaim if already released |
COLLECTED | Verify first |
Anything unrecognised: do not fulfil. See Statuses And Lifecycles.
When It Fails
The payment fails
charge.failed, or FAILED on verification. Let the customer try again. Reuse your order ID as the reference on a retry only if the previous attempt genuinely did not collect — otherwise generate a new one.
The customer never returns from checkout
Common and harmless. They closed the tab. The webhook still arrives if they paid; your reconciliation job catches it if not.
Never treat a missing return as failure — you would abandon orders the customer actually paid for.
The same webhook arrives twice
Expected. Deduplicate on meta.kyshiEventId and make fulfilment idempotent, keyed on your order ID.
A transaction reverses after success
transfer.reversed and the REVERSAL status can arrive after you have fulfilled. If you sell anything revocable — credit, access, digital goods — you need a path to unwind.
You get a 404 verifying a transaction you just created
Almost always a mode mismatch: a live key reading a test-mode transaction, or the reverse. See Test Mode And Sandbox.
Reconcile
async function reconcilePendingTransactions() {
const pending = await db.orders.pendingLongerThan({ minutes: 30 });
for (const order of pending) {
const tx = await kyshi.transactions.verify(order.reference);
if (tx.status === 'SUCCESS') {
await fulfilOnce(order.id); // idempotent
} else if (tx.status === 'FAILED') {
await markFailed(order.id);
}
// PENDING, IN_REVIEW: leave for the next pass.
}
}Store meta.fxRate and meta.feeBreakdown on your own record at the time of the sale. Without them you cannot explain why a $10 sale settled at $9.87. See Settlements.
Test It
Initialize a transaction with a test key and complete it through the returned checkout URL. Then confirm your system handles:
- A successful payment, fulfilled exactly once.
- The same webhook delivered twice, still fulfilled once.
- A failed payment the customer can retry.
- A customer who never returns from checkout.
Next
- Initialize Transaction — full schema
- Verify Transaction — the authoritative check
- Retrieve Transaction History — for reconciliation
- Statuses And Lifecycles
Updated 5 days ago
