GuidesAccept a payment

Accept a payment

How to take a customer payment end-to-end. Multi-rail (card / MoMo / wallet) through a single API.

The pattern is intentionally Stripe-shaped — if you’ve integrated Stripe, this will feel familiar.

The flow

┌──────────┐     1. Create PaymentIntent       ┌──────────┐
│ Your     │──────────────────────────────────▶│ Soxara   │
│ server   │◀──────────────────────────────────│ API      │
└──────────┘     2. client_secret              └──────────┘

     │ 3. Hand client_secret to your frontend

┌──────────┐
│ Your     │     4. Confirm with payment       ┌──────────┐
│ frontend │──────method (card / MoMo / wallet)─▶ Soxara   │
└──────────┘                                   │ API      │
                                                └────┬─────┘


┌──────────┐     5. payment.succeeded webhook    ┌────────────┐
│ Your     │◀───────────────────────────────────│ Soxara     │
│ webhook  │                                    │ event bus  │
└──────────┘                                    └────────────┘

Two key principles:

  • Server creates the PaymentIntent. Never let the customer’s browser create it directly — they could tamper with the amount. The intent is your declaration of “this customer will pay $X.”
  • Webhook is the source of truth. Not the frontend’s “thanks” callback. The browser can lie, drop, or never finish. The webhook is the only signal you should trust to mark an order as paid.

1. Create the PaymentIntent

On your server, when the customer is ready to pay:

curl -X POST "$SOXARA_BASE/v1/payments" \
  -H "Authorization: Bearer $SOXARA_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: order-${ORDER_ID}-v1" \
  -d '{
    "amount": 1250,
    "currency": "USD",
    "description": "Order #'${ORDER_ID}'",
    "metadata": {
      "order_id": "'${ORDER_ID}'"
    }
  }'

The metadata is yours — Soxara stores it but doesn’t read it. Use it to thread your order ID through. Webhooks echo it back.

Response:

{
  "success": true,
  "data": {
    "id": "pi_3TZ...",
    "client_secret": "pi_3TZ..._secret_...",
    "status": "requires_payment_method",
    "amount": 1250,
    "currency": "USD"
  }
}

Save pi_3TZ... against your order in your database. You’ll use it later to look up the payment in webhooks and refunds.

2. Confirm with a payment method (frontend)

Hand client_secret to your frontend. There it confirms with whatever payment method the customer picks.

Card — use Soxara’s hosted Elements (drop-in form for card details with PCI scope shifted to us):

<script src="https://js.soxara.com/v1"></script>
<div id="payment-form"></div>
<script>
  const soxara = Soxara('sxm_test_<your_publishable_key>');
  const form = soxara.elements({ clientSecret: 'pi_3TZ..._secret_...' });
  form.mount('#payment-form');
 
  form.on('submit', async (e) => {
    e.preventDefault();
    const result = await soxara.confirmPayment({
      elements: form,
      confirmParams: { return_url: 'https://yoursite.com/order/return' },
    });
    if (result.error) { /* show error to user */ }
    // Don't trust the result on its own — wait for the webhook
  });
</script>

MTN MoMo / Orange Money — call the confirm endpoint with the customer’s phone:

await soxara.confirmPayment({
  clientSecret: 'pi_3TZ..._secret_...',
  paymentMethod: {
    type: 'momo',
    momo: {
      provider: 'mtn',      // or 'orange'
      phone_number: '+231881158457',
    },
  },
});
// PaymentIntent transitions to `requires_action` — customer gets a PIN prompt
// on their MoMo app. After they approve, you get a `payment.succeeded` webhook.

Soxara wallet — if the customer is already a Soxara user, they tap a button in your checkout that opens Soxara and asks them to approve from their app:

await soxara.confirmPayment({
  clientSecret: 'pi_3TZ..._secret_...',
  paymentMethod: { type: 'soxara_wallet' },
});

3. Mark the order paid via webhook

In your webhook handler (see Handle a webhook), wait for the payment.succeeded event:

if (event.type === 'payment.succeeded') {
  const payment = event.data.object;
  const orderId = payment.metadata.order_id;
 
  // Mark order as paid. Idempotent — the same event ID won't be processed
  // twice because of the dedupe layer in your handler.
  await db.query(
    `UPDATE orders
        SET status = 'paid', paid_at = NOW(), payment_id = $1
      WHERE id = $2 AND status IN ('pending', 'awaiting_payment')`,
    [payment.id, orderId],
  );
}

The WHERE status IN (...) guard prevents the worker from flipping a manually-canceled order back to “paid” in the rare case it processes the webhook out of order.

4. Show the receipt

After the customer’s frontend confirms the payment, redirect them to a “thanks” page. Don’t make business decisions on the frontend. Just show whatever you’ve already recorded on the server:

// frontend
window.location = `/orders/${orderId}/thanks`;

On the thanks page, look up the order in your DB and render based on its current status. If the webhook has fired, status is paid and you show a receipt. If the webhook hasn’t fired yet (network slow), status is awaiting_payment and you show “processing — we’ll email you when it’s confirmed.”

What to test

Before going live, walk through:

  1. Happy path card — test card 4242 4242 4242 4242. Confirm webhook fires, order updates.
  2. Card declined — test card 4000 0000 0000 0002. Confirm payment.failed webhook fires, you show the right error.
  3. 3DS challenge — test card 4000 0027 6000 3184. Confirm the redirect flow lands back on your site.
  4. MoMo happy path — any valid Liberian MSISDN in test mode. Confirm payment.succeeded fires after the synthetic 5-second delay.
  5. RefundPOST /v1/refunds against a succeeded payment. Confirm refund.succeeded fires.
  6. Idempotent retry — replay the same payment.succeeded event through your handler. Confirm your order doesn’t get double-paid.
  7. Out-of-order delivery — process refund.succeeded before payment.succeeded for the same payment. Confirm your handler doesn’t crash.

Test all six in sandbox before swapping in your live key.