# 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: - **A cart with items and a handoff mode set** -- See the [Cart Lifecycle guide](/online-ordering/guides/04-cart-lifecycle) for the full cart setup flow. - **An access token** -- See the [Authentication guide](/online-ordering/guides/02-authentication). - **Familiarity with idempotency keys** -- Every payment request requires a unique `Idempotency-Key` header. See the [Cart Lifecycle guide](/online-ordering/guides/04-cart-lifecycle#idempotency) for details. 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. ### Step 1: Calculate prices (recommended) Before checkout, call the price calculation endpoint to get the current total. This value is used for optimistic concurrency in the next step. ```python 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: ```json { "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 ```python 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):** ```json { "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. ```python 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):** ```json { "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. ```mermaid stateDiagram-v2 [*] --> PENDING : Payment created PENDING --> AUTHORIZED : Pre-auth approved PENDING --> COMPLETED : Immediate capture PENDING --> FAILED : Declined / error AUTHORIZED --> CAPTURED : Capture initiated AUTHORIZED --> VOIDED : Authorization reversed AUTHORIZED --> FAILED : Capture failed CAPTURED --> COMPLETED : Settlement complete CAPTURED --> REFUNDED : Full refund CAPTURED --> PARTIALLY_REFUNDED : Partial refund COMPLETED --> REFUNDED : Full refund COMPLETED --> PARTIALLY_REFUNDED : Partial refund PARTIALLY_REFUNDED --> REFUNDED : Remaining refunded ``` ### All 8 payment states | State | Description | Terminal? | Typical online ordering flow | | --- | --- | --- | --- | | `PENDING` | Payment created, processing not started. | No | Initial state for every payment. | | `AUTHORIZED` | Funds reserved but not captured. Used in pre-auth flows. | No | Rare in online ordering -- most payments capture immediately. | | `CAPTURED` | Funds captured, pending settlement. | No | Intermediate step before COMPLETED in pre-auth flows. | | `COMPLETED` | Payment settled successfully. The customer has been charged. | Yes | **Expected end state.** Most payments go PENDING -> COMPLETED. | | `VOIDED` | Authorization reversed before capture. No charge occurred. | Yes | Happens during order cancellation if payment was only authorized. | | `REFUNDED` | Full refund processed. The customer has been fully reimbursed. | Yes | After a full refund is issued. | | `PARTIALLY_REFUNDED` | Some funds refunded, remainder still held. | No | After a partial refund (e.g., one item unavailable). | | `FAILED` | Payment declined or errored. No charge occurred. | Yes | Card 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](/online-ordering/guides/10-cstore-features#split-payment-with-restricted-items) 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)** ```python 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:** ```json { "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)** ```python # 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:** ```json { "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)** ```python # 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:** ```json { "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 | Tender | Method | Amount | Tip | Idempotency-Key | | --- | --- | --- | --- | --- | | 1 | LOYALTY_POINTS | $5.00 | -- | `11111111-1111-4111-8111-111111111111` | | 2 | GIFT_CARD | $7.50 | -- | `22222222-2222-4222-8222-222222222222` | | 3 | CREDIT_CARD | $6.95 | $2.00 | `33333333-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: ```python # 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. ```python # 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. ```python # 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. ```python 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. ```python 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):** ```json { "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: ```python 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):** ```json { "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 | Reason | When to use | | --- | --- | | `CUSTOMER_REQUEST` | Customer asked for a refund (no quality or error issue). | | `ITEM_UNAVAILABLE` | An ordered item was out of stock. | | `INCORRECT_ORDER` | The wrong items were prepared or delivered. | | `QUALITY_ISSUE` | Items did not meet quality standards. | | `DUPLICATE_CHARGE` | Customer was charged more than once for the same order. | | `OTHER` | Any other reason. Requires `reason_note` to be provided. | ## Error Reference | Status | Error | Cause | Resolution | | --- | --- | --- | --- | | 400 | `BAD_REQUEST` | Malformed request body. | Check JSON structure and field types. | | 402 | `PAYMENT_DECLINED` | Payment was declined by the processor. | Ask the customer to try a different payment method or card. | | 404 | `NOT_FOUND_ERROR` | Order not found. | Verify the order ID. | | 409 | `CONFLICT_ERROR` | Idempotency key reused with a different payload, or order already fully paid. | Use a new UUID for different requests. Check `balance_due` before submitting. | | 422 | `INVALID_REQUEST_ERROR` | Validation failed (e.g., amount exceeds balance_due, missing required fields). | Check `balance_due` on the order and ensure all required fields are present. | | 429 | `TOO_MANY_REQUESTS` | Rate limit exceeded. | Wait for `Retry-After` seconds, then retry. | ## Next Steps - [Order Tracking guide](/online-ordering/guides/08-order-tracking) -- 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](/online-ordering/spec/openapi) -- Full endpoint specification for `POST /orders/{order_id}/payments`.