Accept a payment
This guide shows you how to accept a one-time payment. You'll create an order with payment details, confirm customer intent with an OTP, then wait for payment authorization. The entire flow takes two API calls and about 30 seconds.
How it works
Every payment follows a three-phase pattern: create the order, confirm customer intent, then authorize the payment. When you create an order with execute_payment: true, Commerce sends a 6-digit OTP to the customer's phone. The customer shares this code with you through your app, proving they initiated the transaction. Once you submit the OTP via /orders/confirm_payment, the order transitions to authorize_payment status—Commerce then waits for the customer to authorize the charge with their mobile money provider. The order status moves from requires_payment → requires_confirmation → requires_authorization → paid → completed as it progresses.
Step 1: Create the order
Creating an order bundles everything about the transaction—who's paying, what they're buying, and how they'll pay—into a single atomic unit. You'll provide either customer_data for new customers or customer_id for returning ones, along with line_items describing the products, fees, or shipping charges. When you set execute_payment: true, Commerce immediately sends a 6-digit OTP to the customer's phone. The response includes an order.id you'll need for confirmation and a next_action object telling you the OTP was sent and when it expires.
Create order
curl https://api.zebo.dev/orders/new \
-H "Authorization: Bearer YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"idempotency_key": "order_2025_001",
"execute_payment": true,
"customer_data": {
"name": "Gloria Kesewaa",
"email_address": "gloria@example.com",
"phone_number": "+233544998605"
},
"payment_method_data": {
"type": "mobile_money",
"mobile_money": {
"issuer": "mtn",
"number": "0544998605"
}
},
"line_items": [
{
"type": "product",
"product": {
"type": "physical",
"name": "Utility Sneakers",
"quantity": 1,
"price": { "currency": "ghs", "value": 20000 }
}
}
]
}'
Response
This is a partial response showing key attributes. See the complete Order object for all available fields.
{
"order": {
"id": "48ZW7BGvYUBWc1i6WBkL2jr0iPQP5jUy76mmmHpt",
"status": "requires_payment",
"customer": {
"id": "cus_abc123",
"name": "Gloria Kesewaa",
"email_address": "gloria@example.com"
},
"payment": {
"id": "pay_xyz789",
"status": "requires_action",
"payment_method": {
"id": "pm_saved_method",
"type": "mobile_money",
"issuer": "mtn"
},
"next_action": {
"type": "confirm_payment",
"confirm_payment": {
"expires_at": "2025-01-13T10:08:00Z",
"request": {
"recipient": "0544998605",
"sent_via": "sms"
}
}
}
}
}
}
Key attributes:
order.id- Store this for the next stepcustomer.id- Save for future orders from this customerpayment_method.id- Save to charge this customer again without re-entering payment detailspayment.status: "requires_action"- Waiting for OTPconfirm_payment.expires_at- OTP expires in ~8 minutes
Step 2: Confirm customer intent with OTP
The customer receives an SMS with a 6-digit OTP that proves they initiated the transaction. Collect this code through your UI, then send it along with the order_id to confirm customer intent. Once verified, Commerce transitions the order to authorize_payment status and prompts the customer to approve the charge with their mobile money provider via USSD or SMS.
Confirm payment
curl https://api.zebo.dev/orders/confirm_payment \
-H "Authorization: Bearer YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"order_id": "48ZW7BGvYUBWc1i6WBkL2jr0iPQP5jUy76mmmHpt",
"token": "302673"
}'
Done! Check the order status to confirm payment succeeded:
Check status
curl https://api.zebo.dev/orders/lookup \
-H "Authorization: Bearer YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"order_id": "48ZW7BGvYUBWc1i6WBkL2jr0iPQP5jUy76mmmHpt"
}'
What happens after payment
When you create an order with customer_data and payment_method_data, Commerce automatically creates a customer record and attaches the payment method to it. The response includes both customer.id and payment_method.id—store these for future use. Next time this customer checks out, you can skip collecting their details again and charge them instantly. See Charge Repeat Customers to learn how.
Common patterns
Multiple items
Real shopping carts contain more than just products—there are shipping charges, processing fees, and taxes. The line_items array supports three types: product, shipping, and fee. Each line item has its own structure with a type discriminator and a nested object containing the details. Commerce automatically sums all the line items to calculate the order total, which you'll see in the line_item_group.total field of the response.
line_items: [
{
type: 'product',
product: {
name: 'Shoes',
quantity: 2,
price: { currency: 'ghs', value: 25000 },
},
},
{
type: 'shipping',
shipping: {
fee: { currency: 'ghs', value: 2000 },
},
},
{
type: 'fee',
fee: {
label: 'Processing Fee',
amount: { currency: 'ghs', value: 500 },
},
},
]
Handling errors
OTP expired or wrong
OTP codes expire after about 8 minutes, and customers sometimes mistype them. When either happens, call /orders/request_confirmation to generate and send a fresh code. The customer can retry with the new code without losing their order or having to start over.
await commerce.orders.requestConfirmation({
order_id: '48ZW7BGvYUBWc1i6WBkL2jr0iPQP5jUy76mmmHpt',
})
Customer doesn't have funds
If the customer approves the payment but their mobile money account has insufficient balance, the payment attempt fails after OTP confirmation. The order remains in requires_payment status and you'll see a failed status with an error code in payment.latest_attempt. You can prompt them to add funds and retry, or offer an alternative payment method.
const order = await commerce.orders.lookup({ order_id })
if (order.payment.status === 'failed') {
const error = order.payment.latest_attempt.error
console.log(error.code) // "insufficient_funds"
}
Testing
Before going live, test your integration end-to-end using special mobile money numbers that simulate real payment behavior. Point your requests to api-test.zebo.dev and use your test API key. In test mode, all OTP codes are 000000 regardless of which test number you use, so you can automate your testing without waiting for real SMS messages.
| Number | Behavior |
|---|---|
0242000001 | ✓ Always succeeds |
0242000002 | ✗ Insufficient funds |
0242000003 | Requires OTP (use 000000) |
curl https://api-test.zebo.dev/orders/new \
-H "Authorization: Bearer TEST_KEY" \
-d '{
"execute_payment": true,
"customer_data": {
"name": "Test User",
"phone_number": "+233242000003"
},
"payment_method_data": {
"type": "mobile_money",
"mobile_money": { "issuer": "mtn", "number": "0242000003" }
},
"line_items": [...]
}'
# Then confirm with test OTP
curl https://api-test.zebo.dev/orders/confirm_payment \
-d '{"order_id": "test_order_id", "token": "000000"}'
Key tips
Idempotency: Network requests can fail and get retried, but you don't want to charge customers twice. Always include an idempotency_key that's unique per checkout attempt—if you retry the request with the same key, Commerce returns the existing order instead of creating a duplicate. Use a combination of user ID and cart ID, never random values.
// ✓ Good: Same key = safe to retry
idempotency_key: `order_${userId}_${cartId}`
// ✗ Bad: Creates new order every time
idempotency_key: `order_${Math.random()}`
Phone format: Mobile money providers require phone numbers in E.164 format with country code (e.g., +233544998605). If customers enter local format like 0544998605, prepend the country code before sending to Commerce—otherwise the OTP won't be delivered.
Store order ID: The moment you receive the order creation response, persist order.id to your database. You'll need this ID for payment confirmation, status lookups, and linking the Commerce order to your internal records. Don't wait until after OTP confirmation—store it immediately.
Complete example
Here's a full implementation showing order creation, OTP handling, confirmation, and error checking. This example includes database persistence and proper status verification—use it as a starting template for your integration.
import Commerce from '@example/commerce-sdk'
const commerce = new Commerce(process.env.COMMERCE_SECRET_KEY!)
// 1. Create and charge
const order = await commerce.orders.create({
idempotency_key: `order_${userId}_${Date.now()}`,
execute_payment: true,
customer_data: {
name: 'Gloria Kesewaa',
email_address: 'gloria@example.com',
phone_number: '+233544998605',
},
payment_method_data: {
type: 'mobile_money',
mobile_money: { issuer: 'mtn', number: '0544998605' },
},
line_items: [
{
type: 'product',
product: {
type: 'physical',
name: 'Sneakers',
quantity: 1,
price: { currency: 'ghs', value: 20000 },
},
},
],
})
// Save order ID
await db.orders.create({
commerce_order_id: order.id,
user_id: userId,
})
// 2. Show OTP input to customer
if (order.payment.status === 'requires_action') {
const otp = await promptCustomerForOTP()
// 3. Confirm
await commerce.orders.confirmPayment({
order_id: order.id,
token: otp,
})
// 4. Verify success
const updated = await commerce.orders.lookup({ order_id: order.id })
if (updated.payment.status === 'paid') {
console.log('Payment successful!')
}
}
Next steps
- Charge repeat customers - Fast checkout for returning customers
- Retry payment with new payment method - Retry failed payments with alternative methods
- Order now, pay later - Create orders without immediate payment
- Orders API - Full API reference with all parameters
- Webhooks - Get notified when payment status changes
- Refunds - Handle returns and cancellations
That's it! You're ready to accept payments.