Skip to content

How to cancel unpaid orders automatically without losing late payments

How to cancel unpaid orders automatically: set a payment expiration time, close the checkout first, and handle a buyer who pays after cancellation.

Published 4 min read

Hand-drawn illustration of an order card with an hourglass, a 30-minute timer and a closed padlock over a checkout page, next to a person with hands in pockets
On this page
  1. Why "paid after cancelled" happens
  2. Set a payment expiration time on every order
  3. Close an order early
  4. Your auto-cancel job, step by step
  5. What if a buyer pays after the order was closed anyway?
  6. Unpaid orders from payment links and epay
  7. Summary

Most shops cancel unpaid orders automatically after a timeout — 30 minutes, 2 hours, a day — to release stock and keep the order list clean. The trap is the buyer who is still on the checkout page when your timer fires: they pay a minute later, the money is taken, and your system has already cancelled the order. Now you owe a manual refund and an apology.

The fix is simple: the checkout must close before your order does. This guide explains how to cancel unpaid orders automatically without that race — set a payment expiration time on each order, close an order early when you need to, and know what happens to a payment that arrives late anyway.

Why "paid after cancelled" happens

Your shop and your payment provider each have a clock:

  • your order timeout, after which your system cancels the order and releases stock;
  • the checkout's lifetime, during which the buyer can still pay.

If the checkout lives longer than your order, there is a window where the buyer can pay for an order you consider dead. On KukoPay a checkout link lives 24 hours by default — far longer than most shops' unpaid-order timeouts.

Set a payment expiration time on every order

Pass expires_at when you create the order, no later than your own cancel time:

curl -X POST "https://api.kukopay.com/v1/orders" \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $KUKOPAY_API_KEY" \
  -d '{
    "out_trade_no": "ORD_20261105_0001",
    "amount": 2990,
    "currency": "USD",
    "notify_url": "https://shop.example.com/webhooks/kukopay",
    "expires_at": "2026-11-05T10:30:00Z"
  }'
  • expires_at takes an ISO-8601 time or Unix seconds. It must be at least 5 minutes away and can only shorten the default 24 hours, never extend it; a value outside that range is refused with 400 invalid_request, never silently adjusted.
  • The response's expires_at is the order's real deadline.
  • Leave a margin. Set it a few minutes before your own cancel time, so a 3D Secure check or wallet approval started in the last minute can finish before your timer fires.
  • Retries are safe. Repeating the request with the same out_trade_no returns the original order with its original deadline — expires_at is not compared — so recalculating "now + 30 minutes" on each retry causes no conflict.

When the deadline passes unpaid, the order becomes expired and you receive payment.expired. That event is a good trigger for releasing stock — subscribe to it, not only to payment.succeeded.

Close an order early

Sold out, the buyer cancelled their cart, or you replaced the order with a new one? Close it now instead of waiting:

curl -X POST "https://api.kukopay.com/v1/orders/ORD_20261105_0001/expire" \
  -H "X-Api-Key: $KUKOPAY_API_KEY" \
  -H "Idempotency-Key: expire-ORD_20261105_0001"

The path takes your out_trade_no or KukoPay's trade_no. The dashboard's order detail page has the same Close order action, for owners and admins.

The platform does not close the order from its own record alone — it asks the payment channel first:

SituationResult
The buyer has not paidThe order is closed: 200, expired, and payment.expired is sent
The buyer has in fact paidThe order is credited, not closed: 409 conflict, and you receive payment.succeeded
The buyer is mid-way through a wallet or 3D Secure step that cannot be cancelled yetThe order stays pending: 409 conflict — try again shortly
The channel cannot be reachedThe order stays pending: 502 processor_error — retry with the same idempotency key
The order is already expiredIt is returned as it is, 200
The order is paid, refunded or failed409 conflict

On any 409, look the order up again and act on its current status — especially when a buyer paid at the very moment you tried to close it.

Your auto-cancel job, step by step

Create every order with expires_at

A few minutes before your own cancel time.

Let payment.expired drive the cancellation

When it arrives, cancel the order and release stock. No race: the checkout is already closed.

Cancelling for another reason? Close it first

Call the expire endpoint, and cancel on your side only after it answers 200 — or after payment.expired arrives.

Make the job idempotent

Cancel only orders still awaiting payment. An order already paid or cancelled is left alone, so running the job twice changes nothing.

Never cancel because the buyer left the page or never came back from the redirect — that says nothing about payment. return_url vs notify_url explains why.

What if a buyer pays after the order was closed anyway?

Rarely, a payment still lands after the order has been closed — for example, the order was already paid by another attempt, or it had expired. On KukoPay that payment is refunded to the buyer in full automatically, and you receive payment.duplicate_refunded. It never touches your balance, carries no fee and needs no action from you. If the order was still pending when the payment arrived, it is simply credited as normal and you get payment.succeeded.

So you never have to chase a refund for a late payment — but subscribe to payment.duplicate_refunded if your support team should know about it.

  • Payment links create an order on every visit, so visits nobody paid are orders too. They expire with their checkout link, and the dashboard's order list hides them unless you tick Include payment link visits nobody paid. See what is a payment link.
  • Epay (易支付) orders always carry a deadline from the credential's order timeout, 30 minutes by default, because the epay protocol has no field for one. Keep it shorter than the connected system's own cancel time — the epay integration guide explains why.

Summary

  • Unpaid orders get cancelled safely only if the checkout closes before your order does;
  • pass expires_at on every order — at least 5 minutes away, at most the 24-hour default — with a few minutes' margin before your own timeout;
  • cancel on payment.expired, or close early with POST /v1/orders/{trade_no}/expire, which checks with the channel and credits a payment that already happened;
  • on 409, read the order again before deciding;
  • a payment that arrives after the order closed is refunded automatically, with payment.duplicate_refunded.

Try it in the sandbox

Create a merchant account and request a test key. The sandbox needs no business verification.