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 with an OTP, then wait for provider authorization. The entire flow typically completes in under 30 seconds. For detailed information on order and payment status transitions at each step, see the Order lifecycle guide.


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 begins payment execution and sends a 6-digit OTP to the customer's phone.

Create order

POST
/orders/new
curl https://api.zebo.dev/orders/new \
  -H "Authorization: Bearer $COMMERCE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "request_meta": {
    "idempotency_key": "order_2025_001"
  },
  "execute_payment": true,
  "customer_data": {
    "name": "Gloria Kesewaa",
    "email_address": "[email protected]",
    "phone_number": "+233544998605"
  },
  "payment_method_data": {
    "type": "mobile_money",
    "mobile_money": {
      "network": "mtn",
      "account_number": "0544998605"
    }
  },
  "line_items": [
    {
      "type": "product",
      "product": {
        "type": "physical",
        "name": "Utility Sneakers",
        "quantity": 1,
        "price": {
          "currency": "ghs",
          "value": 20000
        }
      }
    }
  ]
}'

Response

{
  "order": {
    "id": "or_48ZW7BGvYUBWc1i6WBkL2jr0iPQP5jUy76mmmHpt",
    "status": "requires_payment",
    "customer": {
      "id": "cu_abc123",
      "name": "Gloria Kesewaa",
      "email_address": "[email protected]"
    },
    "payment": {
      "id": "py_xyz789",
      "status": "requires_action",
      "payment_method": {
        "id": "pm_saved_method",
        "type": "mobile_money",
        "network": "mtn"
      },
      "next_action": {
        "type": "confirm_payment",
        "confirm_payment": {
          "expires_at": "2025-01-13T10:08:00Z",
          "request": {
            "id": "otc_req_8Ks2Vn",
            "recipient": "0544998605",
            "sent_via": "sms"
          }
        }
      }
    }
  }
}

Key attributes:

  • order.id - Store this for the next step
  • payment.next_action.type: "confirm_payment" - OTP collection needed
  • confirm_payment.expires_at - OTP expires in ~8 minutes
  • customer.id - Save for future orders from this customer
  • payment_method.id - Save to charge this customer again without re-entering payment details

Step 2: Confirm customer intent with OTP

The customer receives a confirmation token. Collect it through your UI and submit it with the order, payment, and confirmation-request IDs from the preceding response. Those four values bind the token to the exact payment action being confirmed.

Done! Check the order status to confirm payment succeeded:

Check status

POST
/orders/lookup
curl https://api.zebo.dev/orders/lookup \
  -H "Authorization: Bearer $COMMERCE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "order_id": "or_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.

const response = await fetch(
  'https://api.zebo.dev/orders/request_confirmation',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.COMMERCE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      order_id: 'or_48ZW7BGvYUBWc1i6WBkL2jr0iPQP5jUy76mmmHpt',
    }),
  },
)
if (!response.ok) {
  throw new Error(await response.text())
}

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 response = await fetch('https://api.zebo.dev/orders/lookup', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.COMMERCE_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ order_id }),
})
if (!response.ok) throw new Error(await response.text())
const { order } = await response.json()

if (order.payment.status === 'failed') {
  const error = order.payment.latest_attempt.error
  console.log(error.code) // "insufficient_funds"
}

Testing

Use an application configured for the test environment and send the opaque API key copied from the dashboard to the standard API host. Use only payment details and confirmation values supplied for that environment. When a payment requires confirmation, follow Confirm a payment with order_id, payment_id, confirmation_id, and the supplied token; do not hardcode a universal token value.


Key tips

Idempotency: Network requests can fail and get retried, but you don't want to charge customers twice. Always include a stable key through request_meta.idempotency_key or the Idempotency-Key header for each 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
request_meta: {
  idempotency_key: `order_${userId}_${cartId}`
}

// ✗ Bad: Creates new order every time
request_meta: {
  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.

async function postOrder(path: string, payload: unknown) {
  const response = await fetch(`https://api.zebo.dev${path}`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.COMMERCE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  })
  if (!response.ok) throw new Error(await response.text())
  return response.json()
}

// 1. Create and charge
const { order } = await postOrder('/orders/new', {
  request_meta: {
    idempotency_key: `order_${userId}_${Date.now()}`,
  },
  execute_payment: true,
  customer_data: {
    name: 'Gloria Kesewaa',
    email_address: '[email protected]',
    phone_number: '+233544998605',
  },
  payment_method_data: {
    type: 'mobile_money',
    mobile_money: { network: 'mtn', account_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' &&
  order.payment.next_action?.type === 'confirm_payment'
) {
  const otp = await promptCustomerForOTP()

  // 3. Confirm
  const confirmation = order.payment.next_action.confirm_payment.request
  await postOrder('/orders/confirm_payment', {
    confirmation_id: confirmation.id,
    order_id: order.id,
    payment_id: order.payment.id,
    token: otp,
  })

  // 4. Verify success
  const { order: updated } = await postOrder('/orders/lookup', {
    order_id: order.id,
  })

  if (updated.payment.status === 'paid') {
    console.log('Payment successful!')
  }
}

Next steps

That's it! You're ready to accept payments.

Was this page helpful?