Verify Payments

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

ScenarioUse 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

HeaderValueRequired
AuthorizationBearer YOUR_SECRET_KEYYes

Query Parameters

ParameterTypeRequiredDescription
idstringYesYour 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

FieldTypeDescription
successbooleanRequest success status
statusstringRequest status text
status_codenumberHTTP status code
messagestringHuman-readable message
data.idstringTsara payment ID
data.referencestringYour transaction reference
data.statusstringPayment status. See status values below
data.amountnumberPayment amount (in minor units for NGN, full units for USDC)
data.currencystringCurrency code (NGN or USDC)
data.payment_methodstringMethod used: card, bank_transfer, usdc, wallet
data.channelstringPayment source: checkout, payment_link, api
data.customerobjectCustomer information
data.customer.idstringCustomer ID (if exists)
data.customer.emailstringCustomer email
data.customer.phonestringCustomer phone number
data.customer.namestringCustomer full name
data.metaobjectCustom metadata attached during payment creation
data.feesobjectFee breakdown
data.fees.tsara_feenumberTsara platform fee
data.fees.processing_feenumberPayment processor fee (card, bank, etc.)
data.fees.total_feesnumberTotal fees deducted
data.net_amountnumberAmount you receive after fees
data.payment_link_idstringPayment link ID (if payment via payment link)
data.session_idstringCheckout session ID (if payment via checkout)
data.paid_atstringTimestamp when payment was confirmed (ISO 8601)
data.created_atstringTimestamp when payment was initiated
data.updated_atstringTimestamp of last status update
request_idstringUnique 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

ParameterTypeRequiredDescription
payment_idstringYesTsara 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:

StatusDescriptionNext Action
pendingPayment initiated but not yet confirmed. Common for bank transfers.Wait for webhook or poll every 30-60 seconds
successPayment confirmed and successful. Funds will be settled to your account.✅ Fulfill order/deliver service
failedPayment attempt failed (declined card, insufficient funds, timeout).Allow customer to retry with different method
cancelledCustomer cancelled payment before completion.Allow customer to retry
expiredPayment session expired before completion (after 24 hours).Create new payment link/checkout session
reversedPayment was refunded or reversed (chargeback, dispute).Contact customer and support

Status Lifecycle

┌─────────┐
│ pending │──────────────┐
└─────────┘              │
     │                   │
     │ (confirmed)       │ (timeout/error)
     ▼                   ▼
┌─────────┐         ┌─────────┐
│ success │         │ failed  │
└─────────┘         └─────────┘
     │
     │ (chargeback)
     ▼
┌─────────┐
│reversed │
└─────────┘

Pending Payment Timeline

Payment MethodTypical Confirmation TimeMax Pending Time
CardInstant (< 5 seconds)5 minutes
Bank Transfer2-10 minutes24 hours
USDC30-60 seconds (blockchain confirmation)10 minutes
WalletInstant2 minutes

Payment Methods

The payment_method field indicates how the customer paid:

MethodDescriptionConfirmation
cardDebit/credit card (Visa, Mastercard, Verve)Instant
bank_transferBank transfer to virtual account2-10 minutes
usdcUSDC stablecoin payment on Solana30-60 seconds
walletPayment from Tsara wallet balanceInstant

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 amount
  • total_fees - All fees deducted
  • net_amount - What you receive (amount - total_fees)

Example:

  • Customer pays: ₦2,000.00 (200000 kobo)
  • Tsara fee: ₦30.00 (3000 kobo)
  • Processing fee: ₦10.00 (1000 kobo)
  • You receive: ₦1,960.00 (196000 kobo)

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

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

  2. 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;
    }
  3. 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
    });
  4. 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_id with support for faster resolution.

  5. 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');
    }
  6. 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.

  7. 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;
      }
    }
  8. 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:

  1. Check typical confirmation time for payment method
  2. For bank transfers: Wait up to 24 hours
  3. For USDC: Check blockchain explorer using transaction hash
  4. 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_id per 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