Skip to content
Last updated

Checkout & Payments

This guide walks through the complete payment flow for an online order: checking out a cart, submitting payment, handling split payments across multiple tenders, understanding the payment state machine, recovering from failures, and issuing refunds.

By the end, you will know how to turn a cart into a paid order using every payment method the API supports.

Prerequisites

Before following this guide, make sure you have:

All examples in this guide use the sandbox base URL and consistent IDs:

Base URL:    https://sandbox.api.tote.ai/v1/online-ordering
Order ID:    f9a8b7c6-d5e4-3210-fedc-ba9876543210
Cart ID:     c1d2e3f4-a5b6-7890-cdef-1234567890ab
Location ID: b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d

Checkout Flow

Checkout converts an ACTIVE cart into an order. The order is the resource you submit payments against.

Before checkout, call the price calculation endpoint to get the current total. This value is used for optimistic concurrency in the next step.

import requests
from uuid import uuid4

BASE_URL = "https://sandbox.api.tote.ai/v1/online-ordering"
headers = {"Authorization": "Bearer YOUR_ACCESS_TOKEN"}
cart_id = "c1d2e3f4-a5b6-7890-cdef-1234567890ab"

# Calculate the current price
response = requests.post(
    f"{BASE_URL}/carts/{cart_id}/calculate",
    headers=headers,
)
price = response.json()
expected_total = price["total"]["amount"]  # 2093 ($20.93)

The price calculation response includes an itemized breakdown with fees:

{
  "cart_id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab",
  "currency": "USD",
  "line_items": [
    {
      "cart_item_id": "d4e5f6a7-b8c9-0123-4567-890abcdef012",
      "name": "Build Your Own Sub Sandwich",
      "quantity": 1,
      "item_subtotal": { "amount": 1399, "currency": "USD" },
      "item_tax": { "amount": 115, "currency": "USD" }
    },
    {
      "cart_item_id": "e7f8a9b0-c1d2-3456-7890-abcdef123456",
      "name": "Bottled Water",
      "quantity": 2,
      "item_subtotal": { "amount": 398, "currency": "USD" },
      "item_tax": { "amount": 33, "currency": "USD" }
    }
  ],
  "fees": [
    {
      "fee_type": "DELIVERY",
      "label": "Delivery Fee",
      "amount": { "amount": 399, "currency": "USD" },
      "taxable": false
    }
  ],
  "subtotal": { "amount": 1797, "currency": "USD" },
  "total_tax": { "amount": 148, "currency": "USD" },
  "total_fees": { "amount": 399, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "taxable_amount": { "amount": 1797, "currency": "USD" },
  "total": { "amount": 2344, "currency": "USD" }
}

Price formula: total = subtotal + total_tax + total_fees - total_discount

  • subtotal -- sum of line item subtotals (item-level discounts already deducted from each line item)
  • total_tax -- total tax across all items
  • total_fees -- delivery, service, bag, small order, and other fees
  • total_discount -- cart-level discounts only (item-level discounts are already reflected in line item subtotals, so they are not double-counted here)
  • taxable_amount -- the basis on which tax was calculated, exposed for transparency so you can verify tax computations independently

Step 2: Checkout the cart

checkout_response = requests.post(
    f"{BASE_URL}/carts/{cart_id}/checkout",
    headers={**headers, "Idempotency-Key": str(uuid4())},
    json={
        "expected_total": expected_total
    },
)
order = checkout_response.json()
order_id = order["id"]  # f9a8b7c6-d5e4-3210-fedc-ba9876543210

Response (201 Created):

{
  "id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "cart_id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab",
  "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
  "status": "PENDING",
  "payment_status": "UNPAID",
  "fulfillment_status": "PENDING",
  "payments": [],
  "fees": [
    {
      "fee_type": "DELIVERY",
      "label": "Delivery Fee",
      "amount": { "amount": 399, "currency": "USD" },
      "taxable": false
    }
  ],
  "total_fees": { "amount": 399, "currency": "USD" },
  "total": { "amount": 2344, "currency": "USD" },
  "total_paid": { "amount": 0, "currency": "USD" },
  "balance_due": { "amount": 2344, "currency": "USD" },
  "estimated_ready_at": "2026-01-31T10:25:00Z",
  "created_at": "2026-01-31T10:07:00Z",
  "updated_at": "2026-01-31T10:07:00Z"
}

The order response includes fees and total_fees (locked at checkout) and estimated_ready_at (an ISO 8601 timestamp for when the order is expected to be ready). Display the estimated ready time to the customer -- it may be updated as the order progresses and can be null if the store has not yet provided an estimate.

What happens at checkout

  • The cart must be ACTIVE with at least one item.
  • A handoff mode must be set (via PUT /carts/{cart_id}/handoff) or provided in the checkout request body.
  • expected_total is optional but recommended. If the current price differs from this value, the server returns 409 Conflict instead of proceeding with the wrong amount.
  • After checkout, the cart status changes to CHECKED_OUT (immutable -- no more edits).
  • The order is created with status: PENDING, payment_status: UNPAID, and fulfillment_status: PENDING.

Single Payment

The simplest flow: one payment covers the full order total. The API supports seven payment methods: CREDIT_CARD, DEBIT_CARD, CASH, GIFT_CARD, LOYALTY_POINTS, DIGITAL_WALLET, and EBT. Note that CASH means pay-at-counter (only for PICKUP and DINE_IN handoff modes) and EBT is subject to item eligibility per SNAP regulations.

Submit a credit card payment with a tip.

order_id = "f9a8b7c6-d5e4-3210-fedc-ba9876543210"

payment_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/payments",
    headers={**headers, "Idempotency-Key": str(uuid4())},
    json={
        "payment_method": "CREDIT_CARD",
        "amount": {"amount": 1945, "currency": "USD"},
        "tip_amount": {"amount": 200, "currency": "USD"},
        "payment_details": {"token": "tok_visa_4242"}
    },
)
payment = payment_response.json()

Response (201 Created):

{
  "id": "a1b2c3d4-0001-4000-8000-000000000001",
  "order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "status": "COMPLETED",
  "payment_method": "CREDIT_CARD",
  "amount": { "amount": 1945, "currency": "USD" },
  "tip_amount": { "amount": 200, "currency": "USD" },
  "payment_details": {
    "last_four": "4242",
    "brand": "visa",
    "exp_month": 12,
    "exp_year": 2027
  },
  "idempotency_key": "d7a8fbb3-07d4-4e3c-b5f2-9a6c8b1e0f23",
  "created_at": "2026-01-31T10:08:00Z",
  "updated_at": "2026-01-31T10:08:02Z"
}

After this payment completes, the order transitions to status: CONFIRMED and payment_status: PAID. The store begins fulfillment.

Payment State Machine

Every payment has a status field that follows a deterministic state machine. Understanding these states is important for building reliable payment handling.

Payment created

Pre-auth approved

Immediate capture

Declined / error

Capture initiated

Authorization reversed

Capture failed

Settlement complete

Full refund

Partial refund

Full refund

Partial refund

Remaining refunded

PENDING

AUTHORIZED

COMPLETED

FAILED

CAPTURED

VOIDED

REFUNDED

PARTIALLY_REFUNDED

All 8 payment states

StateDescriptionTerminal?Typical online ordering flow
PENDINGPayment created, processing not started.NoInitial state for every payment.
AUTHORIZEDFunds reserved but not captured. Used in pre-auth flows.NoRare in online ordering -- most payments capture immediately.
CAPTUREDFunds captured, pending settlement.NoIntermediate step before COMPLETED in pre-auth flows.
COMPLETEDPayment settled successfully. The customer has been charged.YesExpected end state. Most payments go PENDING -> COMPLETED.
VOIDEDAuthorization reversed before capture. No charge occurred.YesHappens during order cancellation if payment was only authorized.
REFUNDEDFull refund processed. The customer has been fully reimbursed.YesAfter a full refund is issued.
PARTIALLY_REFUNDEDSome funds refunded, remainder still held.NoAfter a partial refund (e.g., one item unavailable).
FAILEDPayment declined or errored. No charge occurred.YesCard declined, insufficient funds, or processing error.

For typical online ordering, expect the simple path: PENDING -> COMPLETED. The AUTHORIZED and CAPTURED states are used in pre-authorization flows (e.g., fuel pump pre-auth) and are uncommon for food and merchandise orders.

Terminal states

COMPLETED, VOIDED, REFUNDED, and FAILED are terminal -- no further forward transitions. The only exception is that COMPLETED can move to REFUNDED or PARTIALLY_REFUNDED when a refund is issued.

Split Payments

Split payments let a customer pay with multiple tenders on a single order. Common scenarios include redeeming loyalty points for part of the total and paying the remainder with a credit card.

For carts with tender-restricted items, see the C-Store Features guide for how restrictions affect available payment methods.

The 5 split payment rules

  1. Submit tenders one at a time. Each tender is a separate POST /orders/{order_id}/payments call with its own Idempotency-Key.
  2. Wait for each payment to reach a terminal state (COMPLETED or FAILED) before submitting the next. Concurrent submissions may produce undefined behavior.
  3. Order of tenders matters. Submit in this order: loyalty points first, then gift cards, then credit/debit cards. This maximizes non-cash tender redemption.
  4. The sum of all successful payment amounts must equal the order total. The server tracks balance_due on the order -- query GET /orders/{order_id} between tenders to check the remaining balance.
  5. Tips go on the final tender. Only include tip_amount on the last payment in the sequence.

3-tender walkthrough

This example pays for a $19.45 order with loyalty points ($5.00), a gift card ($7.50), and a credit card ($6.95 + $2.00 tip).

Tender 1: Loyalty points ($5.00)

order_id = "f9a8b7c6-d5e4-3210-fedc-ba9876543210"

# Tender 1: Loyalty points
loyalty_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/payments",
    headers={**headers, "Idempotency-Key": "11111111-1111-4111-8111-111111111111"},
    json={
        "payment_method": "LOYALTY_POINTS",
        "amount": {"amount": 500, "currency": "USD"},
        "payment_details": {"loyalty_account_id": "LOY-123456"}
    },
)
loyalty_payment = loyalty_response.json()
# loyalty_payment["status"] == "COMPLETED"

Response:

{
  "id": "a1b2c3d4-0002-4000-8000-000000000001",
  "order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "status": "COMPLETED",
  "payment_method": "LOYALTY_POINTS",
  "amount": { "amount": 500, "currency": "USD" },
  "tip_amount": null,
  "payment_details": {
    "points_used": 500,
    "points_remaining": 1200
  },
  "idempotency_key": "11111111-1111-4111-8111-111111111111",
  "created_at": "2026-01-31T10:08:00Z",
  "updated_at": "2026-01-31T10:08:01Z"
}

After tender 1, the order's payment_status is PARTIALLY_PAID and balance_due is $14.45 (1445 cents).

Tender 2: Gift card ($7.50)

# Tender 2: Gift card (wait for tender 1 to complete first)
gift_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/payments",
    headers={**headers, "Idempotency-Key": "22222222-2222-4222-8222-222222222222"},
    json={
        "payment_method": "GIFT_CARD",
        "amount": {"amount": 750, "currency": "USD"},
        "payment_details": {
            "card_number": "6789012345678901",
            "pin": "1234"
        }
    },
)
gift_payment = gift_response.json()
# gift_payment["status"] == "COMPLETED"

Response:

{
  "id": "a1b2c3d4-0003-4000-8000-000000000001",
  "order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "status": "COMPLETED",
  "payment_method": "GIFT_CARD",
  "amount": { "amount": 750, "currency": "USD" },
  "tip_amount": null,
  "payment_details": {
    "last_four": "7890",
    "balance_remaining": { "amount": 1500, "currency": "USD" }
  },
  "idempotency_key": "22222222-2222-4222-8222-222222222222",
  "created_at": "2026-01-31T10:08:10Z",
  "updated_at": "2026-01-31T10:08:11Z"
}

After tender 2, balance_due is $6.95 (695 cents).

Tender 3: Credit card ($6.95 + $2.00 tip)

# Tender 3: Credit card with tip (final tender)
credit_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/payments",
    headers={**headers, "Idempotency-Key": "33333333-3333-4333-8333-333333333333"},
    json={
        "payment_method": "CREDIT_CARD",
        "amount": {"amount": 695, "currency": "USD"},
        "tip_amount": {"amount": 200, "currency": "USD"},
        "payment_details": {"token": "tok_visa_4242"}
    },
)
credit_payment = credit_response.json()
# credit_payment["status"] == "COMPLETED"

Response:

{
  "id": "a1b2c3d4-0004-4000-8000-000000000001",
  "order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "status": "COMPLETED",
  "payment_method": "CREDIT_CARD",
  "amount": { "amount": 695, "currency": "USD" },
  "tip_amount": { "amount": 200, "currency": "USD" },
  "payment_details": {
    "last_four": "4242",
    "brand": "visa",
    "exp_month": 12,
    "exp_year": 2027
  },
  "idempotency_key": "33333333-3333-4333-8333-333333333333",
  "created_at": "2026-01-31T10:08:20Z",
  "updated_at": "2026-01-31T10:08:22Z"
}

After all three tenders complete: payment_status: PAID, balance_due: 0, and the order moves to status: CONFIRMED.

Payment breakdown summary

TenderMethodAmountTipIdempotency-Key
1LOYALTY_POINTS$5.00--11111111-1111-4111-8111-111111111111
2GIFT_CARD$7.50--22222222-2222-4222-8222-222222222222
3CREDIT_CARD$6.95$2.0033333333-3333-4333-8333-333333333333
Total$19.45$2.00

Handling Split Payment Failures

When a tender fails mid-sequence, previously successful payments remain in place. There is NO automatic rollback of previously successful payments when a later tender fails.

Failure scenario

Suppose tender 1 (loyalty, $5.00) succeeded but tender 2 (gift card, $7.50) fails:

# Tender 2 fails -- gift card declined
gift_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/payments",
    headers={**headers, "Idempotency-Key": "22222222-2222-4222-8222-222222222222"},
    json={
        "payment_method": "GIFT_CARD",
        "amount": {"amount": 750, "currency": "USD"},
        "payment_details": {
            "card_number": "6789012345678901",
            "pin": "0000"
        }
    },
)
# gift_response.status_code == 402
# gift_response.json()["error"]["code"] == "PAYMENT_DECLINED"

At this point the order has:

  • payment_status: PARTIALLY_PAID (loyalty payment is still COMPLETED)
  • balance_due: 1445 ($14.45 remaining)
  • The failed gift card payment has status: FAILED

Three options for recovery

Option A: Retry the failed tender

Use a new Idempotency-Key since error responses are not cached. The customer may want to try a different gift card or re-enter the PIN.

# Retry with a new gift card and new idempotency key
retry_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/payments",
    headers={**headers, "Idempotency-Key": str(uuid4())},
    json={
        "payment_method": "GIFT_CARD",
        "amount": {"amount": 750, "currency": "USD"},
        "payment_details": {
            "card_number": "9876543210123456",
            "pin": "5678"
        }
    },
)

Option B: Use a different tender

Skip the gift card entirely and pay the remaining balance with a credit card.

# Pay remaining $14.45 with credit card instead
fallback_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/payments",
    headers={**headers, "Idempotency-Key": str(uuid4())},
    json={
        "payment_method": "CREDIT_CARD",
        "amount": {"amount": 1445, "currency": "USD"},
        "tip_amount": {"amount": 200, "currency": "USD"},
        "payment_details": {"token": "tok_visa_4242"}
    },
)

Option C: Cancel the order

If the customer wants to give up, cancel the order. Cancellation automatically voids or refunds all successful payments.

cancel_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/cancel",
    headers={**headers, "Idempotency-Key": str(uuid4())},
    json={"reason": "Customer changed their mind"},
)
# The loyalty points payment is automatically refunded

Important: There is no automatic rollback. If you do not explicitly cancel the order, the loyalty points from tender 1 remain charged. Your client must decide how to proceed.

Refunds

Refunds are issued against an order after payment is complete. The server determines how to distribute the refund across the order's payment methods using a minimize-cash-refund strategy: non-cash tenders (loyalty points, gift cards) are refunded first, then card payments.

Full refund

Refund the entire order total. The server distributes across all payment methods.

order_id = "f9a8b7c6-d5e4-3210-fedc-ba9876543210"

refund_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/refunds",
    headers={**headers, "Idempotency-Key": str(uuid4())},
    json={
        "amount": {"amount": 1945, "currency": "USD"},
        "reason": "CUSTOMER_REQUEST"
    },
)
refund = refund_response.json()

Response (201 Created):

{
  "id": "b1c2d3e4-0001-4000-8000-000000000001",
  "order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "status": "COMPLETED",
  "amount": { "amount": 1945, "currency": "USD" },
  "reason": "CUSTOMER_REQUEST",
  "reason_note": null,
  "refund_allocations": [
    {
      "payment_id": "a1b2c3d4-0002-4000-8000-000000000001",
      "payment_method": "LOYALTY_POINTS",
      "amount": { "amount": 500, "currency": "USD" }
    },
    {
      "payment_id": "a1b2c3d4-0003-4000-8000-000000000001",
      "payment_method": "GIFT_CARD",
      "amount": { "amount": 750, "currency": "USD" }
    },
    {
      "payment_id": "a1b2c3d4-0004-4000-8000-000000000001",
      "payment_method": "CREDIT_CARD",
      "amount": { "amount": 695, "currency": "USD" }
    }
  ],
  "line_items": [],
  "created_at": "2026-01-31T11:00:00Z"
}

Notice the minimize-cash-refund ordering in refund_allocations: loyalty points are refunded first ($5.00), then the gift card ($7.50), then the credit card ($6.95). This ensures non-cash tenders are returned before cash-equivalent methods.

Partial refund (item-level)

Refund specific items. For example, the Bottled Water ($3.98 for 2 units) was out of stock:

partial_refund_response = requests.post(
    f"{BASE_URL}/orders/{order_id}/refunds",
    headers={**headers, "Idempotency-Key": str(uuid4())},
    json={
        "amount": {"amount": 398, "currency": "USD"},
        "reason": "ITEM_UNAVAILABLE",
        "reason_note": "Bottled water was out of stock.",
        "line_items": [
            {
                "order_item_id": "e7f8a9b0-c1d2-3456-7890-abcdef123456",
                "quantity": 2
            }
        ]
    },
)
partial_refund = partial_refund_response.json()

Response (201 Created):

{
  "id": "b1c2d3e4-0002-4000-8000-000000000001",
  "order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "status": "COMPLETED",
  "amount": { "amount": 398, "currency": "USD" },
  "reason": "ITEM_UNAVAILABLE",
  "reason_note": "Bottled water was out of stock.",
  "refund_allocations": [
    {
      "payment_id": "a1b2c3d4-0002-4000-8000-000000000001",
      "payment_method": "LOYALTY_POINTS",
      "amount": { "amount": 398, "currency": "USD" }
    }
  ],
  "line_items": [
    {
      "order_item_id": "e7f8a9b0-c1d2-3456-7890-abcdef123456",
      "quantity": 2,
      "reason": null
    }
  ],
  "created_at": "2026-01-31T11:30:00Z"
}

Key points about refunds:

  • The amount field determines the refund total -- line_items is informational for record-keeping.
  • The refund amount must not exceed the order's refundable balance (total_paid - total_refunded). Exceeding it returns 422.
  • Multiple partial refunds can be issued until the full amount has been refunded.
  • After a partial refund, the affected payment's status changes to PARTIALLY_REFUNDED. After a full refund, it changes to REFUNDED.

Refund reason codes

ReasonWhen to use
CUSTOMER_REQUESTCustomer asked for a refund (no quality or error issue).
ITEM_UNAVAILABLEAn ordered item was out of stock.
INCORRECT_ORDERThe wrong items were prepared or delivered.
QUALITY_ISSUEItems did not meet quality standards.
DUPLICATE_CHARGECustomer was charged more than once for the same order.
OTHERAny other reason. Requires reason_note to be provided.

Error Reference

StatusErrorCauseResolution
400BAD_REQUESTMalformed request body.Check JSON structure and field types.
402PAYMENT_DECLINEDPayment was declined by the processor.Ask the customer to try a different payment method or card.
404NOT_FOUND_ERROROrder not found.Verify the order ID.
409CONFLICT_ERRORIdempotency key reused with a different payload, or order already fully paid.Use a new UUID for different requests. Check balance_due before submitting.
422INVALID_REQUEST_ERRORValidation failed (e.g., amount exceeds balance_due, missing required fields).Check balance_due on the order and ensure all required fields are present.
429TOO_MANY_REQUESTSRate limit exceeded.Wait for Retry-After seconds, then retry.

Next Steps

  • Order Tracking guide -- Monitor order fulfillment status, poll for updates, and handle cancellations.
  • Webhooks guide (coming in Phase 4) -- Receive real-time push notifications for payment status changes and order updates instead of polling.
  • API Reference: Submit Payment -- Full endpoint specification for POST /orders/{order_id}/payments.