After collecting a payment (via Payment Link or Checkout), you can verify its status using the Verify Payment API. This ensures your server always knows whether a payment was successful, failed, or pending — even if the customer closes the page before redirect. ***
Overview
- Use the payment reference to check transaction status.
- Works for both fiat (NGN) and stablecoin (USDC) transactions.
- Recommended for backend confirmation before delivering goods or services.
- Webhooks provide real-time notifications, but this API is useful as a backup check.
When to Use Payment Verification
| Scenario | Use Verification API |
|---|---|
| After redirect | ✅ Always verify before fulfilling order |
| Webhook backup | ✅ Verify if webhook delayed or missed |
| Customer inquiry | ✅ Check payment status on demand |
| Reconciliation | ✅ Batch verify transactions daily |
| Real-time updates | ❌ Use webhooks instead (faster) |
Verify Payment by Transaction Reference
Get payment details using your transaction reference (trx_id).
Endpoint
GET /payments?id={trx_id}
Headers
| Header | Value | Required |
|---|---|---|
Authorization | Bearer YOUR_SECRET_KEY | Yes |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Your transaction reference (the trx_id used when creating payment link or checkout) |
Example Request
curl -X GET "https://sandbox.tsara.ng/v1/payments?id=order_001" \
-H "Authorization: Bearer YOUR_SECRET_KEY"Success Response
{
"success": true,
"status": "success",
"status_code": 200,
"message": "Payment retrieved",
"data": {
"id": "pay_695fc9b9d4e992",
"reference": "order_001",
"status": "success",
"amount": 200000,
"currency": "NGN",
"payment_method": "card",
"channel": "checkout",
"customer": {
"id": "cus_123",
"email": "[email protected]",
"phone": "08012345678",
"name": "John Doe"
},
"meta": {
"order_id": "ORD-123",
"customer_id": "cus_456"
},
"fees": {
"tsara_fee": 3000,
"processing_fee": 1000,
"total_fees": 4000
},
"net_amount": 196000,
"payment_link_id": "plink_abc123",
"session_id": "sess_xyz789",
"paid_at": "2025-01-31T12:00:00Z",
"created_at": "2025-01-31T11:55:00Z",
"updated_at": "2025-01-31T12:00:00Z"
},
"request_id": "req_1738318449"
}Response Fields
| Field | Type | Description |
|---|---|---|
success | boolean | Request success status |
status | string | Request status text |
status_code | number | HTTP status code |
message | string | Human-readable message |
data.id | string | Tsara payment ID |
data.reference | string | Your transaction reference |
data.status | string | Payment status. See status values below |
data.amount | number | Payment amount (in minor units for NGN, full units for USDC) |
data.currency | string | Currency code (NGN or USDC) |
data.payment_method | string | Method used: card, bank_transfer, usdc, wallet |
data.channel | string | Payment source: checkout, payment_link, api |
data.customer | object | Customer information |
data.customer.id | string | Customer ID (if exists) |
data.customer.email | string | Customer email |
data.customer.phone | string | Customer phone number |
data.customer.name | string | Customer full name |
data.meta | object | Custom metadata attached during payment creation |
data.fees | object | Fee breakdown |
data.fees.tsara_fee | number | Tsara platform fee |
data.fees.processing_fee | number | Payment processor fee (card, bank, etc.) |
data.fees.total_fees | number | Total fees deducted |
data.net_amount | number | Amount you receive after fees |
data.payment_link_id | string | Payment link ID (if payment via payment link) |
data.session_id | string | Checkout session ID (if payment via checkout) |
data.paid_at | string | Timestamp when payment was confirmed (ISO 8601) |
data.created_at | string | Timestamp when payment was initiated |
data.updated_at | string | Timestamp of last status update |
request_id | string | Unique request identifier for support/debugging |
Payment Not Found Response
{
"success": false,
"status": "error",
"status_code": 404,
"message": "Payment not found",
"error": {
"code": "payment_not_found",
"message": "No payment found with reference: order_001"
},
"request_id": "req_1738318450"
}Verify Payment by Payment ID
You can also verify using the Tsara payment ID (from webhook payload or previous API response).
Endpoint
GET /payments?payment_id={payment_id}
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
payment_id | string | Yes | Tsara payment ID (format: pay_*) |
Example Request
curl -X GET "https://sandbox.tsara.ng/v1/payments?payment_id=pay_695fc9b9d4e992" \
-H "Authorization: Bearer YOUR_SECRET_KEY"Response format is identical to verification by transaction reference.
Payment Status Values
Understanding payment statuses and their transitions:
| Status | Description | Next Action |
|---|---|---|
pending | Payment initiated but not yet confirmed. Common for bank transfers. | Wait for webhook or poll every 30-60 seconds |
success | Payment confirmed and successful. Funds will be settled to your account. | ✅ Fulfill order/deliver service |
failed | Payment attempt failed (declined card, insufficient funds, timeout). | Allow customer to retry with different method |
cancelled | Customer cancelled payment before completion. | Allow customer to retry |
expired | Payment session expired before completion (after 24 hours). | Create new payment link/checkout session |
reversed | Payment was refunded or reversed (chargeback, dispute). | Contact customer and support |
Status Lifecycle
┌─────────┐
│ pending │──────────────┐
└─────────┘ │
│ │
│ (confirmed) │ (timeout/error)
▼ ▼
┌─────────┐ ┌─────────┐
│ success │ │ failed │
└─────────┘ └─────────┘
│
│ (chargeback)
▼
┌─────────┐
│reversed │
└─────────┘
Pending Payment Timeline
| Payment Method | Typical Confirmation Time | Max Pending Time |
|---|---|---|
| Card | Instant (< 5 seconds) | 5 minutes |
| Bank Transfer | 2-10 minutes | 24 hours |
| USDC | 30-60 seconds (blockchain confirmation) | 10 minutes |
| Wallet | Instant | 2 minutes |
Payment Methods
The payment_method field indicates how the customer paid:
| Method | Description | Confirmation |
|---|---|---|
card | Debit/credit card (Visa, Mastercard, Verve) | Instant |
bank_transfer | Bank transfer to virtual account | 2-10 minutes |
usdc | USDC stablecoin payment on Solana | 30-60 seconds |
wallet | Payment from Tsara wallet balance | Instant |
Fee Structure
Every payment includes a fee breakdown in the response:
{
"fees": {
"tsara_fee": 3000,
"processing_fee": 1000,
"total_fees": 4000
},
"net_amount": 196000
}Fee Calculation
amount- Original payment amounttotal_fees- All fees deductednet_amount- What you receive (amount - total_fees)
Example:
- Customer pays: ₦2,000.00 (
200000kobo) - Tsara fee: ₦30.00 (
3000kobo) - Processing fee: ₦10.00 (
1000kobo) - You receive: ₦1,960.00 (
196000kobo)
Verification Strategies
1. Webhook + Verification (Recommended)
Best practice: Use webhooks for real-time notifications, verify for confirmation.
app.post('/webhooks/tsara', async (req, res) => {
const event = req.body;
res.sendStatus(200);
if (event.type === 'payment.success') {
const verified = await verifyPayment(event.data.payment.reference);
if (verified.status === 'success') {
await fulfillOrder(verified.reference);
}
}
});
async function verifyPayment(reference) {
const response = await fetch(
`https://sandbox.tsara.ng/v1/payments?id=${reference}`,
{
headers: {
'Authorization': `Bearer ${SECRET_KEY}`
}
}
);
const data = await response.json();
return data.data;
}2. Redirect Callback + Verification
After payment, verify before showing confirmation page.
app.get('/payment/callback', async (req, res) => {
const { trx_id, status } = req.query;
const payment = await verifyPayment(trx_id);
if (payment.status === 'success') {
await fulfillOrder(payment.reference);
res.redirect(`/order/success?order=${payment.meta.order_id}`);
} else if (payment.status === 'pending') {
res.redirect(`/order/pending?ref=${trx_id}`);
} else {
res.redirect(`/order/failed?ref=${trx_id}`);
}
});3. Polling for Pending Payments
For bank transfers, poll until status changes from pending.
async function waitForPaymentConfirmation(reference, maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
const payment = await verifyPayment(reference);
if (payment.status === 'success') {
return { success: true, payment };
}
if (payment.status === 'failed' || payment.status === 'cancelled') {
return { success: false, payment };
}
await sleep(30000);
}
return { success: false, timeout: true };
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}Polling Best Practices:
- Initial check: Immediately after payment initiation
- Poll interval: 30 seconds for bank transfers, 5 seconds for cards
- Max duration: 15 minutes for bank transfers, 2 minutes for cards
- Exponential backoff: Increase interval after 5 minutes
4. Batch Verification (Reconciliation)
Verify multiple payments for daily reconciliation.
async function reconcilePayments(references) {
const results = {
success: [],
pending: [],
failed: [],
missing: []
};
for (const ref of references) {
try {
const payment = await verifyPayment(ref);
results[payment.status].push(payment);
} catch (error) {
if (error.status === 404) {
results.missing.push(ref);
}
}
}
return results;
}Error Responses
Payment Not Found (404)
{
"success": false,
"status_code": 404,
"error": {
"code": "payment_not_found",
"message": "No payment found with reference: order_001"
}
}Invalid Reference Format (400)
{
"success": false,
"status_code": 400,
"error": {
"code": "invalid_reference",
"message": "Transaction reference is invalid"
}
}Unauthorized (401)
{
"success": false,
"status_code": 401,
"error": {
"code": "unauthorized",
"message": "Invalid or missing API key"
}
}Rate Limit Exceeded (429)
{
"success": false,
"status_code": 429,
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests. Please retry after 60 seconds."
},
"retry_after": 60
}Use Cases & Examples
E-commerce Order Fulfillment
async function processOrder(orderId) {
const order = await getOrder(orderId);
const payment = await verifyPayment(order.transaction_reference);
if (payment.status === 'success') {
await updateOrderStatus(orderId, 'confirmed');
await sendConfirmationEmail(order.customer_email);
await shipOrder(orderId);
return { success: true };
} else if (payment.status === 'pending') {
await updateOrderStatus(orderId, 'awaiting_payment');
return { success: false, message: 'Payment pending' };
} else {
await updateOrderStatus(orderId, 'payment_failed');
return { success: false, message: 'Payment failed' };
}
}Subscription Activation
async function activateSubscription(userId, transactionRef) {
const payment = await verifyPayment(transactionRef);
if (payment.status === 'success') {
const plan = payment.meta.plan;
const duration = payment.meta.billing_cycle;
await createSubscription({
userId,
plan,
duration,
startDate: new Date(payment.paid_at),
amount: payment.amount,
paymentId: payment.id
});
await notifyUser(userId, 'Subscription activated');
return true;
}
return false;
}Customer Support Payment Lookup
app.get('/admin/payments/lookup', async (req, res) => {
const { email, reference, date } = req.query;
try {
let payment;
if (reference) {
payment = await verifyPayment(reference);
} else if (email) {
payment = await searchPaymentsByEmail(email);
}
res.json({
success: true,
payment,
display: {
status: payment.status.toUpperCase(),
amount: formatCurrency(payment.amount, payment.currency),
method: payment.payment_method,
date: new Date(payment.created_at).toLocaleDateString(),
customer: payment.customer.email
}
});
} catch (error) {
res.status(404).json({
success: false,
message: 'Payment not found'
});
}
});Daily Reconciliation
async function dailyReconciliation(date) {
const transactions = await getExpectedTransactions(date);
const report = {
date,
total_expected: transactions.length,
verified: 0,
pending: 0,
failed: 0,
missing: 0,
discrepancies: []
};
for (const txn of transactions) {
try {
const payment = await verifyPayment(txn.reference);
if (payment.status === 'success') {
report.verified++;
if (payment.amount !== txn.expected_amount) {
report.discrepancies.push({
reference: txn.reference,
expected: txn.expected_amount,
actual: payment.amount
});
}
} else if (payment.status === 'pending') {
report.pending++;
} else {
report.failed++;
}
} catch (error) {
report.missing++;
}
}
await saveReconciliationReport(report);
return report;
}Tips & Best Practices
-
Always verify server-side
app.get('/payment/success', async (req, res) => { const verified = await verifyPayment(req.query.ref); if (verified.status !== 'success') { return res.redirect('/payment/failed'); } res.render('success', { payment: verified }); });Never trust client-side redirect parameters alone.
-
Handle all payment statuses
switch (payment.status) { case 'success': await fulfillOrder(); break; case 'pending': await schedulePollCheck(); break; case 'failed': case 'cancelled': await notifyCustomerToRetry(); break; case 'expired': await createNewPaymentSession(); break; case 'reversed': await handleRefund(); break; } -
Store verification results
await db.payments.create({ reference: payment.reference, payment_id: payment.id, status: payment.status, amount: payment.amount, currency: payment.currency, verified_at: new Date(), raw_response: payment }); -
Use request_id for debugging
try { const payment = await verifyPayment(ref); } catch (error) { console.error('Verification failed', { reference: ref, request_id: error.response?.data?.request_id, error: error.message }); }Share
request_idwith support for faster resolution. -
Implement retry logic
async function verifyPaymentWithRetry(ref, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await verifyPayment(ref); } catch (error) { if (error.status === 429) { await sleep(error.retry_after * 1000); continue; } throw error; } } throw new Error('Max retries exceeded'); } -
Cache verification results
const cache = new Map(); async function getCachedPayment(ref) { if (cache.has(ref)) { const cached = cache.get(ref); if (cached.status === 'success') { return cached; } } const payment = await verifyPayment(ref); if (payment.status === 'success') { cache.set(ref, payment); } return payment; }Only cache successful payments to avoid stale pending statuses.
-
Monitor verification latency
async function monitoredVerification(ref) { const start = Date.now(); try { const payment = await verifyPayment(ref); const duration = Date.now() - start; metrics.record('payment_verification', duration); return payment; } catch (error) { metrics.increment('payment_verification_errors'); throw error; } } -
Validate metadata
const payment = await verifyPayment(ref); if (payment.meta.order_id !== order.id) { throw new Error('Payment metadata mismatch'); }
Troubleshooting
Payment shows pending for too long
Cause: Bank transfer not yet confirmed or blockchain confirmation pending.
Solution:
- Check typical confirmation time for payment method
- For bank transfers: Wait up to 24 hours
- For USDC: Check blockchain explorer using transaction hash
- Contact support if pending > 24 hours
Amount mismatch between order and payment
Cause: Customer paid different amount or currency conversion issues.
Solution:
const payment = await verifyPayment(ref);
const expectedAmount = order.total;
if (payment.amount !== expectedAmount) {
console.error('Amount mismatch', {
expected: expectedAmount,
received: payment.amount,
difference: payment.amount - expectedAmount
});
if (payment.amount < expectedAmount) {
await createPartialPaymentRecord();
}
}Payment not found immediately after creation
Cause: Payment link/checkout created but customer hasn't initiated payment yet.
Solution:
- Wait for customer to actually pay
- Don't verify immediately after creating payment link
- Verify only after receiving webhook or redirect callback
Verification API returns 404 for known payment
Cause: Using wrong reference or payment ID format.
Solution:
const correctRef = order.transaction_id;
const wrongRef = order.id;
const payment = await verifyPayment(correctRef);Multiple payments with same reference
Cause: Customer retried payment without new transaction reference.
Solution:
- Always generate unique
trx_idper payment attempt - Use format:
order_${orderId}_${timestamp}_${random}
Status stuck as "pending" after webhook says "success"
Cause: Eventual consistency - verification API may lag behind webhook.
Solution:
async function waitForStatusSync(ref, expectedStatus, maxWait = 60000) {
const start = Date.now();
while (Date.now() - start < maxWait) {
const payment = await verifyPayment(ref);
if (payment.status === expectedStatus) {
return payment;
}
await sleep(2000);
}
throw new Error('Status sync timeout');
}Related Pages
- Payment Links — Create and manage payment links
- Checkout — Accept payments in your app
- Webhooks — Handle
payment.successandpayment.failedevents - Webhooks Security — Signature verification guide
- Errors & Status Codes — Complete error reference