Bug adding Tip to Terminal Checkout

Hi,
I’m having issues with the “tip_money” parameter in the /v2/terminals/checkouts API. I get a 400 error AFTER the payment goes through.

{"errors": [{"code": "BAD_REQUEST","detail": "The checkout's total amount=191 does not match the order's total amount=209","field": "amount","category": "INVALID_REQUEST_ERROR"}]}

Although I receive the 400 error, I still get charged and get payment.created & payment.updated webhook events with the “APPROVED” and “COMPLETED” status.

Here’s my setup. I use /v2/orders to create an order referencing the catalog items, then I use /v2/terminals/checkouts to post a payment request on the terminal device referencing the order_id.

This is the request body:
{'checkout': {'amount_money': {'amount': 191, 'currency': 'USD'},
              'deadline_duration': 'PT45S',
              'device_options': {'device_id': <device_id>,
                                 'tip_settings': {'allow_tipping': False}},
              'order_id': 'WZyaNDWWXbdnTYn7ko8i7TnbxLRZY',
              'tip_money': {'amount': 18, 'currency': 'USD'}},
 'idempotency_key': <idemp key>}

The device charges $2.09 which includes the tip amount. I’m attaching how the confirmation appears on the device too. At this point, after I pay, I get the 400 Error mentioned above, when in fact the payment went through. What’s the fix for this? I’m using Square API version 2023-06-08

NOTE: I was previously using “service_charges” in the orders API but this appears under the Service charges category in reporting. This is an issue since its taxable. Moreover, *it needs to be under “Tips” in the reports, the same way it appears when payments are taken through the POS app.

Thanks for bringing this to our attention. I’ve reached out to the team. :slightly_smiling_face:

We took a look at the account and the reason the Tips are showing $0 is because those payments are all refunded. If you take a payment with the tip and don’t initially refund it, it will show correctly in the Reporting section. :slightly_smiling_face:

Hi Bryan,
Thank you for your response. Let me clarify since that wasn’t exactly my question. The issue I’m facing is getting a 400 error when I’m adding the “tip_money” parameter to the terminal checkout.

When I create a terminal checkout, I keep polling the endpoint /v2/terminals/checkouts till a change in the status. But after the payment goes through, the response I get is the 400 Error below causing my application to record it as a failed payment and attempt a retry.

{"errors": [{"code": "BAD_REQUEST","detail": "The checkout's total amount=191 does not match the order's total amount=209","field": "amount","category": "INVALID_REQUEST_ERROR"}]}

But in reality, it is a successful payment as I get the payment.created & payment.updated webhooks with “APPROVED” and “COMPLETED” status. This behavior happens only when I use the “tip_money” parameter in the checkout request. So my question is why do I get the 400 error?

What are the steps to reproduce the error? I haven’t been able to replicate this error in production? :slightly_smiling_face:

Sure here’s how you can reproduce this error.

  1. Create an order with a catalog item.
  2. Create a terminal checkout with the Order ID.
  3. Keep polling the checkout endpoint till a change in status.
    Sharing the code below.
square_api_version = "2023-06-08"
location_id = "LWPZ39VC4DGW9"
device_serial_id="241CS149B2002939"
headers = {
        "Authorization": "Bearer {}".format(access_token),
        "Square-Version": square_api_version,
        "Content-Type": "application/json"
    }
def create_order(catalog_object_id, order_num):
    data = {
        "idempotency_key": str(uuid.uuid4()),
        "order": {
            "location_id": location_id,
            "source": {
                "name": "KIOSK"
            },
            "ticket_name": "Order #"+str(order_num),
            "line_items":[{
                    "catalog_object_id": catalog_object_id,
                    "quantity": "1",
                }],
            "fulfillments": [
                {
                    "uid": str(uuid.uuid4()),
                    "type": "PICKUP",
                    "state": "PROPOSED",
                    "pickup_details": {
                        "recipient": {
                            "display_name": "Order #"+str(order_num),
                        },
                        "schedule_type": "ASAP",
                        "prep_time_duration": "PT15M", 
                    }
                }
            ]
        }
    }
    order_create_url = "https://connect.squareup.com/v2/orders"
    res = requests.post(order_create_url, headers=headers, data = json.dumps(data))
    if res.status_code == 200:
        order_json = json.loads(res.content)['order']
        order_id = order_json['id']
        total_amt = order_json['net_amount_due_money']['amount']
    return order_id, total_amt


def check_status(data, headers, status_dict):
    checkout_url =  "https://connect.squareup.com/v2/terminals/checkouts"
    response = requests.post(checkout_url, headers=headers, data=json.dumps(data)) 
    if response.status_code == 200:
        status_dict['status'] = json.loads(response.text)['checkout']['status']
        print("Current status: {}".format(status_dict['status']))
    else:
        status_dict['status'] = "FAILED"
        print(f"Unexpected status code: {response.status_code}, response: {response.text}")
    return status_dict['status'] in {'COMPLETED', 'CANCELED', 'CANCEL_REQUESTED', 'FAILED'}

def create_payment(order_id, total_amt, tip_amt):
    data = {
        "idempotency_key": str(uuid.uuid4()),
        "checkout" : {
            "amount_money": {
            "amount": total_amt,
            "currency": "USD",
            },
            "device_options": {
                "skip_receipt_screen": True,
                "tip_settings": {
                "allow_tipping": False,
                },
                "device_id" : device_serial_id,
                "show_itemized_cart": False,
            },
        "deadline_duration": 'PT45S',
        "order_id": order_id,
        "tip_money": {
            "amount": tip_amt,
            "currency": "USD"
        }
        }
    }
    status_dict = {'status': None}
    ## post the checkout and keep polling the endpoint until the status is changed
    polling.poll(lambda: check_status(data,headers, status_dict), step=2, timeout=60)
    return status_dict['status']


order_num= "1241"
catalog_object_id = "FEXNJ2QUE5B76B7JUKTUYXZN"
order_id, total_amt = create_order(catalog_object_id, order_num)
tip_amt = 18   # sample tip_amt 
print(create_payment(order_id, total_amt, tip_amt))

Output:

Unexpected status code: 400, response: {"errors": [{"code": "BAD_REQUEST","detail": "The checkout's total amount=175 does not match the order's total amount=193","field": "amount","category": "INVALID_REQUEST_ERROR"}]}

Okay, I see what the problem is here. Your polling CreateTerminalCheckout instead of polling GetTerminalCheckout with the checkout_id. Once the order is paid for you get this error when polling CreateTerminalCheckout which is expected on a post to the endpoint trying to create checkouts. You’ll need to use GetTerminalCheckout after creating it. :slightly_smiling_face:

Thank you. That works!