KantoPay API
Accept payments and deliver in-game rewards.
Not sure which you need? Compare the two APIs.
FAQ
Do you support subscriptions?
Not yet. Subscriptions are coming; for now every purchase is a one-time payment.
Can a paid order be reversed?
Yes. KantoPay can refund it (a store asks KantoPay support; stores can’t refund from the dashboard or the API), and a buyer can win a chargeback. The order’s reversal_type says which, and /rewards/revocations or the order.refunded event tells your server what to take back.
How do I make sure each server only gets its own rewards?
Each API key is bound to a single server. A key only receives deliveries routed to that server (plus any product set to deliver to all servers), so every server in your network polls safely with its own key.
What happens if my plugin polls twice?
Delivery is idempotent. A command stays in the pending pool until acknowledged, and acknowledging is safe to retry.
Glossary
QRPh — the Philippine national QR standard; one QR pays from any participating bank or e-wallet.
Payment link — a hosted checkout page (and QR) for a single order, tracked from pending to paid.
Reward / command — the in-game action delivered to a server when an order settles.
Idempotent — safe to call more than once; repeats don’t double-charge or double-deliver.
Storefront API
Build your own store on the same catalogue, pricing and checkout as the hosted store: a website, a Discord bot or an in-game menu. Requests need no key, only your public storefront token (kps_…) from Dashboard › Servers.
It works from a static site: plain HTML and JavaScript on GitHub Pages, Netlify, Cloudflare Pages or any web host, with no server of your own.
No secret API key is needed. Two actions take a token of their own: a player’s orders need that player’s player_token (from /players/link), and cancelling an order needs the cancel_token its checkout returned.
https://api.kantopay.com/v1/shops/kps_63bde17336594cbda90a0b7f5dd460d6/products
See one running at example.kantopay.com, a whole store in one HTML file. Why a token and not your slug? · Capabilities
Quick start
Read the catalogue, price the cart, send the buyer to checkout. You need a published product and your storefront token; use your test token (kps_test_…) while you build, so no real money moves. Pick a language: none of them needs a library or a download.
# bash, macOS, Linux or Git Bash. Use your test token while you build. SHOP=https://api.kantopay.com/v1/shops/kps_test_your_token # 1. What's for sale. Copy a product id; show effective_amount (the price after any sale). curl -sS $SHOP/products # 2. Price the cart. Send the same cart to checkout so both price the same thing. CART='"player_name":"Steve","items":[{"product_id":"PRODUCT_ID","quantity":1}]' curl -sS -X POST $SHOP/quote -H 'Content-Type: application/json' -d "{$CART}" # 3. Take the money. Send the buyer to redirect_url; keep order_id. curl -sS -X POST $SHOP/checkout -H 'Content-Type: application/json' \ -d "{$CART,\"buyer_email\":\"me@example.com\",\"idempotency_key\":\"order-1\"}" # Later: is it paid? curl -sS https://api.kantopay.com/v1/orders/ORDER_ID// Run as <script type="module">. Use your test token while you build. const SHOP = 'https://api.kantopay.com/v1/shops/kps_test_your_token'; async function call(path, body) { const res = await fetch(SHOP + path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), } : undefined); // Every error is { code, message }. Branch on code; show message. const data = await res.json() .catch(() => ({ code: 'bad_response', message: `HTTP ${res.status}` })); if (!res.ok) { const err = new Error(data.message); throw Object.assign(err, { code: data.code, status: res.status }); } return data; } // 1. What's for sale. Show effective_amount (the price after any sale). const { products } = await call('/products'); if (!products.length) throw new Error('Publish a product in the dashboard first.'); // 2. One cart for the quote AND the checkout, so both price the same thing. const cart = { player_name: 'Steve', items: [{ product_id: products[0].id, quantity: 1 }], promo_code: 'LAUNCH20', // optional }; const quote = await call('/quote', cart); // A bad coupon is an answer, not an error: show it and drop it. if (cart.promo_code && !quote.coupon_ok) { console.warn(quote.coupon_message); delete cart.promo_code; } // blocked: a purchase limit or opening hours. Checkout would refuse too. if (quote.blocked) throw new Error(quote.blocked.message); if (quote.below_minimum) throw new Error(`Orders start at ₱${quote.min_charge}.`); console.log(`Total: ₱${quote.total}`); // put this on your Pay button // 3. Take the money. One idempotency key per purchase: a double click makes one order. const order = await call('/checkout', { ...cart, buyer_email: 'me@example.com', idempotency_key: crypto.randomUUID(), }); // Keep the order id to check when the buyer comes back, then go to the pay page. sessionStorage.setItem('kp_order', order.order_id); location.href = order.redirect_url; // Back on your site: GET /v1/orders/{order_id} says when it is paid.# Python 3, standard library only. Use your test token while you build. import json, uuid, urllib.request, urllib.error SHOP = 'https://api.kantopay.com/v1/shops/kps_test_your_token' def call(path, body=None): req = urllib.request.Request( SHOP + path, data=json.dumps(body).encode() if body else None, headers={'Content-Type': 'application/json'}, ) try: with urllib.request.urlopen(req) as res: return json.load(res) except urllib.error.HTTPError as e: err = json.load(e) # every error is { code, message } raise RuntimeError(f"{err['code']}: {err['message']}") # 1. What's for sale. Show effective_amount (the price after any sale). products = call('/products')['products'] if not products: raise SystemExit('Publish a product in the dashboard first.') # 2. One cart for the quote AND the checkout, so both price the same thing. cart = { 'player_name': 'Steve', 'items': [{'product_id': products[0]['id'], 'quantity': 1}], 'promo_code': 'LAUNCH20', # optional } quote = call('/quote', cart) if cart.get('promo_code') and not quote['coupon_ok']: print(quote['coupon_message']) # a bad coupon is an answer: show it, drop it del cart['promo_code'] if quote['blocked']: raise SystemExit(quote['blocked']['message']) if quote['below_minimum']: raise SystemExit(f"Orders start at {quote['min_charge']}.") print('Total:', quote['total']) # money is a string: '69.00' # 3. Take the money. One idempotency key per purchase. order = call('/checkout', {**cart, 'buyer_email': 'me@example.com', 'idempotency_key': str(uuid.uuid4())}) print('Pay here:', order['redirect_url']) # send the buyer this link print('Order:', order['order_id']) # GET /v1/orders/{order_id} says when it is paid<?php // PHP 7.4+, no libraries. Use your test token while you build. const SHOP = 'https://api.kantopay.com/v1/shops/kps_test_your_token'; function call(string $path, ?array $body = null): array { $ctx = stream_context_create(['http' => [ 'method' => $body ? 'POST' : 'GET', 'header' => "Content-Type: application/json\r\n", 'content' => $body ? json_encode($body) : '', 'ignore_errors' => true, // read error bodies too ]]); $data = json_decode(file_get_contents(SHOP . $path, false, $ctx), true); preg_match('{HTTP/\S+ (\d+)}', $http_response_header[0], $m); if ($m[1] >= 400) { // every error is { code, message } throw new RuntimeException("{$data['code']}: {$data['message']}"); } return $data; } // 1. What's for sale. Show effective_amount (the price after any sale). $products = call('/products')['products']; if (!$products) exit("Publish a product in the dashboard first.\n"); // 2. One cart for the quote AND the checkout, so both price the same thing. $cart = [ 'player_name' => 'Steve', 'items' => [['product_id' => $products[0]['id'], 'quantity' => 1]], 'promo_code' => 'LAUNCH20', // optional ]; $quote = call('/quote', $cart); if (isset($cart['promo_code']) && !$quote['coupon_ok']) { echo $quote['coupon_message'], "\n"; // a bad coupon is an answer: show it, drop it unset($cart['promo_code']); } if ($quote['blocked']) exit($quote['blocked']['message'] . "\n"); if ($quote['below_minimum']) exit("Orders start at ₱{$quote['min_charge']}.\n"); echo "Total: ₱{$quote['total']}\n"; // 3. Take the money. One idempotency key per purchase. $order = call('/checkout', $cart + [ 'buyer_email' => 'me@example.com', 'idempotency_key' => bin2hex(random_bytes(16)), ]); echo "Pay here: {$order['redirect_url']}\n"; // send the buyer this link echo "Order: {$order['order_id']}\n"; // GET /v1/orders/{order_id} says when it is paid
Before you ship: money, verified players, tiered prices, quotes
Money is always a string — "499.00", never 499.0. A float cannot hold ₱0.10 exactly, and a storefront that rounds is a storefront that loses money. Parse with a decimal library, or keep it as a string until you print it.
Prove who the player is. player_name is an unverified string — anyone can type anyone’s username. To prove it, your game server calls POST /players/link with its API key when a player types /link, and sends them the returned URL as a clickable message. Your storefront reads ?kp_player= off that URL and passes it back as player_token on /products, /quote and /checkout. A verified token replaces the typed name for pricing and delivery, so the discount and the goods always land on the same person. Sending no token still works exactly as before; sending one that doesn’t verify is 401 invalid_player_token, never a quiet fall back to the typed name.
Tiered products need a player name. If a product has a cumulative_group, its price depends on who is buying — an upgrade costs the difference, not the full tier. Pass player_name to /products to render the grid correctly, and note that only one product per group may be in a cart at a time. Personalised requests are throttled on the same budget as /quote, because the prices reveal what that player has already spent on the ladder.
Quote before you charge. /quote and /checkout run the same pricing code, so the same cart prices the same way. A quote is not a promise: if a sale ends, a coupon runs out or the player buys elsewhere in between, checkout prices the cart again and charges the new total, so show the total on the pay page as final. It also tells you below_minimum before the buyer hits the ₱1 floor, so you can disable your Pay button instead of failing at the last step. Cards are offered only from ₱100 (card_min_charge on /v1/shops/{token}); below that, checkout returns the other methods. The quote also returns lines (what each line costs after upgrade pricing, adding up to subtotal), payment_methods (what checkout will offer for that total), free_order (a coupon or gift card covers everything, so checkout settles the order at once with no payment step) and blocked: null, or the {code, message} checkout would refuse with as a 409 — a purchase limit reached, or a product outside its opening hours. Show blocked.message and keep Pay disabled.
Payment status
Poll GET /v1/orders/{id} until it reports paid. Check reversal_type too: an order can be paid and later refunded or charged back, and if you grant entitlements on paid you need to know when to revoke them. Check is_test as well — a test order charges no real money, and a client that can’t tell will ship real goods for play money.
Test orders
Rehearse the whole flow without real money: checkout, payment, settlement and delivery. You need two things from Dashboard › Servers, and neither exists until you make it.
- A test storefront token (
kps_test_…). Use it in place of your token. Every Storefront API read answers as usual, andGET /v1/shops/{token}says"is_test": true. Checkouts and gift cards made with it are test orders, at the real price. - A test server, whose key starts
kp_test_. Put that key in the plugin on a test server. It receives the test orders’ deliveries and nothing else, and its in-game purchases (POST /payments/from-productandPOST /server/checkout) are test orders too. It can’t reach your wallet, orders, coupons or gift cards.
A test order is paid on PayMongo’s test system: the payment page has a button that completes the test payment. When it settles, your balance, payouts and sales figures don’t change, no coupon use or gift card is spent, no gift card is made, the buyer gets no email, and nothing is posted to Discord. Your developer webhook gets sale.paid with "test": true, and each delivery carries test_mode: true. Your live servers never receive a test order. Test orders are deleted 30 minutes after they are made.
Return URLs
After paying, a buyer lands on our receipt at /pay/{id}. To bring them back to your own site, list it under Your website in the dashboard (up to five https:// addresses; your verified custom domain counts without being listed), then send return_url with /checkout or POST /giftcards.
body: JSON.stringify({
player_name: 'Steve', items: cart,
return_url: 'https://store.example.com/thanks',
auto_return: true
})
// After paying, the buyer arrives at
// https://store.example.com/thanks?order_id=9f1e6c1a-…&status=paid
The receipt’s Back to store button goes to return_url with order_id and status added to its query; any query it already has is kept. status is paid, or expired / cancelled when the order ended unpaid. With auto_return: true a paid buyer skips the receipt, unless it shows something they must not miss: a gift card’s code, or the button that gets their Discord role.
An address that is not https, or not on one of your websites, is refused with 400 return_url_not_allowed before any order is made, so a typo shows up on your first test order.
The same buyer checking out the same cart again soon after (within ten minutes, while the first order is unpaid) gets that first order back, with the return_url it was made with. Test a new return_url with a new cart or player name.
Don’t trust the query. Anyone can type ?status=paid into your address bar, or paste the order_id of an older order or of someone else’s receipt. Keep the order_id that /checkout returned with the buyer’s session, and accept only that one, once. Then read GET /v1/orders/{order_id} and check that status is paid, amount is what you expected and is_test is false. shop_slug is only a sanity check that the order is your store’s.
Errors and rate limits
Every failure, on every status, is the same two fields. You need exactly one parser.
{ "code": "cart_too_large", "message": "A cart can hold at most 50 different items." }
Branch on code; show message to the buyer. A 422 adds a third
field, field, naming the parameter at fault.
Each endpoint lists its own responses. Use the response picker on an endpoint to see each status it documents, with an example body and when it happens.
Response headers. Retry-After, X-Request-ID and
Idempotent-Replay are exposed to browsers via Access-Control-Expose-Headers, so
res.headers.get('retry-after') works from a page. X-Request-ID is unique per
request — log it, and quote it if you ever open a support ticket.
Rate limits here are per IP, and per endpoint: quote 60/min, checkout 30/min, gift-card purchase 20/min, order lookup and cancel 120/min together. Debounce your quote calls — a quote on every keystroke will hit the cap. (The Server API is limited differently: 120/min per key. See its own limits.)
API-only mode
To run only your own front end, turn off the hosted storefront (hosted_storefront: false). Your /shop/your-slug pages then return 404; products, the Storefront API, payments and delivery keep working.
The payment page stays on KantoPay: /checkout returns its address as redirect_url, and return_url brings the buyer back to your site.
Embedding
Store pages can’t be shown in an iframe: they send X-Frame-Options: SAMEORIGIN, which also protects the dashboard and the payment page. Link to your hosted store instead (kantopay.com/shop/your-slug, or your own domain), or build your pages with the Storefront API.
Endpoints
Each endpoint sends real requests from Try it. Paste your storefront token (kps_…, not your slug) into its token field.
currency, min_charge (the smallest order, ₱1), card_min_charge (cards are offered only from this amount, ₱100 — leave the card option out below it, checkout does) and small_order (orders under under share a per-buyer budget of max_per_hour at this shop). categories is every category in use across your active products, ready to render as tabs, leaving out any you hid from your store’s navigation. is_test is true when {token} is your test token.amount (list price) and effective_amount (after any running sale): show effective_amount as the catalogue price. With player_name, show that player your_price instead (below). What is actually charged is the total from /quote and checkout. Variants carry both too, and inherit the parent’s sale. cumulative_group names the upgrade ladder a product sits on, or is null. Pass category to filter. has_more is measured, not guessed — and when it is true, next_offset tells you the number to send back as offset for the next page. Pass limit to page in smaller chunks (clamped to 1–250; an out-of-range value is clamped, not rejected). Products come in the order your dashboard lays them out: category by category in your category order (Uncategorised last), then by position inside each, newest first on a tie. Reordering products mid-page-through can repeat or skip one — fine for a shop listing, not for anything that has to be exhaustive.Pass
player_name and every product also gets your_price and owned — the price that player pays, with credit for what they already own on the ladder. Without it a tiered catalogue shows the wrong number to anyone mid-upgrade: Emerald lists at ₱899, but a player who already bought Diamond pays ₱400. owned:true means they have the tier already and the cart will refuse it, so render it as owned rather than as a free item.product_not_found even if the id is real.coupon_ok:false with a reason, not as an error, so you can mark the field without losing the cart. Besides the totals it returns lines, payment_methods, free_order, is_test and blocked, the refusal checkout would give (see Quote before you charge). Use the items box for the JSON array.{ "redirect": "…", "redirect_url": "…", "order_id": "…" } — send the buyer to redirect_url, the pay page’s full address (redirect is the same page as a path on our host, /pay/{id}, which a page on your own domain cannot use as it is), and keep order_id to poll GET /v1/orders/{id} when they come back. A new order also carries cancel_token: keep it if the buyer may cancel, since only this answer has it. (The id used to be reachable only by pulling it out of the redirect URL, which meant hardcoding the shape of a URL we are free to change.) Same body as /quote, but player_name is required, and this one really does redeem the coupon and draw the gift card. The target is the buyer’s /pay/ page; an order a gift card or a 100% coupon pays in full (or the two together) is settled and delivered on the spot and answers the same JSON, its page already the receipt. Send an idempotency_key so a double-tapped Pay makes one order: a repeat with the same key and cart gets the first order back with Idempotent-Replay: true. return_url (an https address on one of your websites, up to 500 characters) is where the buyer goes back to afterwards, with order_id and status added; auto_return: true sends a paid buyer there without stopping at the receipt. See Send buyers back to your website. This creates a real order, so try it with your test token.status (active, paid, expired, cancelled), amount, buyer, paid_method, reversal_type, is_test, and items — the line items with product_id, quantity and the amount each was actually charged, because product_name on a multi-line order is just “Cart (3 items)”, which is not a receipt anyone can read. shop_slug names the store the order belongs to: any order id resolves here, so a site reading order_id off its return URL checks it is its own before trusting the order. The order id is the credential — anyone holding the link can already see this, which is why buyer_email is deliberately left out.For a receipt page:
gift_card_code is the code a paid gift-card order bought (null otherwise, and until payment settles). It shows in full for 30 minutes after payment, then only its first and last four characters, with gift_card_code_masked: true, the same rule as our own receipt; the recipient’s copy went by email. delivery is {total, delivered, waiting_online, failed}: how many in-game reward commands the order has, how many ran, how many wait for the player to join, and how many failed. All zero before payment.cancel_token as the X-Cancel-Token header or ?token=. Only the checkout or gift-card buy that made the order answers with it, so keep it if the buyer may cancel; without it the answer is 404 order_not_found, as for an id that does not exist. Orders made from the dashboard, the API, in game or on Discord have no token: the store cancels those. Cancelling frees the order’s idempotency_key, so the same purchase can be made again as a new order. The order’s gift-card balance, coupon use and purchase-limit units stay held until its QR code’s deadline, 30 minutes from checkout, because that QR code can still be paid. Returns { "id": "…", "status": "cancelled" }. Safe to repeat: a cancelled or expired order answers 200 with its status as it stands. A paid order answers 409 order_paid. A few older orders also have a checkout page at the payment provider; that page is closed first, and if it cannot be closed just then the order stays and the answer is 409 checkout_open, so try again. A payment that still arrives for the cancelled order’s QR code is recorded and delivered as normal. No body. Shares 120 calls a minute per IP with the read above.amount is in pesos, as a string ("500" or "500.00", never centavos), and must be one of the denominations from the GET below, or inside the custom range if you allow one; a custom amount is never under ₱20, even when you left the minimum blank. Returns { "redirect": "…", "redirect_url": "…", "order_id": "…" } exactly like /checkout — the card itself is minted when the payment settles, not when this returns, so poll GET /v1/orders/{id} and read its gift_card_code rather than expecting a code here. Send an idempotency_key, one per purchase the buyer means to make, and reuse it for every retry and double tap of that purchase, so they make one order: the repeat gets the first order back with Idempotent-Replay: true. Without one, every call makes a new order. recipient_email is where the card gets delivered; without one the buyer only ever sees it on the receipt page. buyer_name is optional and shows on the order. return_url and auto_return work as on /checkout, except that auto_return has no effect: the receipt with the card’s code always shows, and its Back button goes to your return_url. A token that is not an open shop answers 404 shop_not_found; an amount that is not on sale answers 400 denomination_unavailable; a return_url that is not on one of your websites answers 400 return_url_not_allowed.valid, remaining and status. A code that does not exist is a 200 with valid:false and status:"not_found", not a 404 — a buyer mistyping their card is an answer, not an error. An expired card reports status:"expired" regardless of what is stored, so this can never disagree with what checkout will accept.Gift-card codes are ~80 bits, so unlike a coupon code they cannot be guessed; that is why this one has no extra throttle and
/coupons/check shares the quote budget.valid plus the coupon’s own settings — kind (percent or fixed), value, min_amount, max_discount, expires_at and product_ids — so you can render “20% off orders over ₱250, up to ₱200” the moment a buyer types it, instead of a bare tick.It answers about the code, not about a cart. Whether this cart clears the minimum, contains an eligible product, or what the peso discount comes to is what
/quote calculates; checkout then creates the order. A rejected code is a 200 with valid:false and a reason you can show the buyer, for the same reason /quote reports it as a field: a mistyped code is an answer, not a broken integration.The gift-card twin is
/giftcards/check.items. The “my purchases” page of a storefront you built yourself.A verified
player_token only — a player name will not do. Everywhere else in the Storefront API an unverified name is allowed, because the worst it does is quote someone their own upgrade price. Here it would hand any visitor the purchase history of any player whose username they can guess. Mint one with POST /players/link after your server has vouched for them.Orders gifted to this player appear with
gifted:true — matched on the same identity that earns cumulative upgrade credit, which is also the identity they were priced against.product_count each, so you can render tabs without fetching the whole catalogue to count them, in the order you set in the dashboard, then by name. Each also carries what you gave it there: id, image_url, blurb and is_ladder (its products are tiers of one rank ladder). A category you never decorated still lists, with id, image_url and blurb null and is_ladder false. A category hidden from your store’s navigation is left out, as it is on your hosted store.slug, title, mode, has_content, show_in_nav and nav_order. Check has_content: pages you wrote in the drag-and-drop builder can only be rendered by us, so for those you link out rather than fetch. System pages are never listed.mode is "simple", content is your own markup and you can render it directly. For a builder page content is null and hosted_url points at the page on our side — we would rather tell you than hand back a layout document only our renderer can draw.{sales:[{name, product, paid_at}]}. Buyer names are always masked (St•••) — buyers are free-text with no account, so they never agreed to be listed. paid_at is RFC 3339, not a pre-rendered “5 minutes ago”, so you format it in your own language and timezone. Test orders never appear. limit 1–25, default 10.{customers:[{name, orders, spent}], period, metric}. Names masked, spent a decimal string. metric=spent (default) or orders; period=day|week|month|year|all, default month; limit 1–25, default 5. An unrecognised period falls back to month rather than erroring.{revenue, currency, period}, revenue a decimal string. period=day|week|month|year|all, default month. Test orders excluded.amount in pesos, as a string), plus your gift-card settings (the custom amount range). Gift cards never expire. POST the same path with amount, buyer_name and recipient_email to sell one; GET /giftcards/check?code= returns a balance. The smallest card is ₱20.Which API to use
Use the Storefront API in anything players can see, such as a website. Use the Server API on machines you control: your game server or your own backend.
Choosing an API
There is one API with a public half and a private half. The Storefront API is every path under /v1/shops/ and /v1/orders/; it needs no credential, and a key holder can call it too. The Server API is everything else (/server, /rewards, /payments, /accounts, /items, /players) and needs your key. What decides which you use is where your code runs.
Can the machine running this code keep a secret?
Yes (your game server, your backend, a bot you host): use your kp_ key, kept in an environment variable such as a .env file.
No (a static site, a browser, a game client, anything you ship to players): use the Storefront API only. It needs no backend. A kp_ key in a web page hands every visitor your orders, your buyers’ emails and your wallet.
Most stores use both: a website that sells through the Storefront API and the plugin on each server that delivers with a key. Both have test orders.
Capabilities
The Server API does everything the Storefront API does, and more: what each player owns and what has expired, every order with real buyer names, your wallet, reward delivery, and coupon and gift-card admin. Products, prices and reward commands are set in the dashboard; neither API can change them.
| Action | Storefront API | Server API | Dashboard |
|---|---|---|---|
| Run from a static site, no backend | Yes | No: needs a server that keeps the key in .env | — |
| Browse products | Yes | Yes | Yes |
| Price carts and create orders | Yes | Yes | Yes |
| View player purchases | Player’s own link | By player name | Yes |
| Deliver rewards | No | Yes | View / retry |
| View wallet and transactions | No | Yes | Yes |
| Manage coupons and gift cards | No | Yes | Yes |
| Edit products, prices, and reward commands | No | No | Yes |
| Configure payouts and cash out | No | No | Yes |
View all capabilities
| Action | Storefront API | Server API | Dashboard |
|---|---|---|---|
| Buying | |||
| Browse products, prices, sales and categories | Yes | Yes | Yes |
| Read your pages and category artwork | Yes | Use the Storefront API | Yes |
| Price a cart (coupons, gift cards, upgrade tiers) | Yes | Yes | Yes |
| Create an order | Yes | Yes | Yes |
| Buy a gift card, check its balance | Yes | Yes | Yes |
| Check a coupon code | Yes | Yes | Yes |
| Cancel an unpaid order | With its cancel token | Any unpaid order | Yes |
| Make test orders | Test token | Test server key | Yes |
| Orders and players | |||
| Look up an order | By order id | All orders, searchable | Yes |
| See what a player has paid for | Player’s own link | By player name | Yes |
| See what a player owns now, and what expired | No | Yes | Order list only |
| Read a player’s receipts, refunds included | No | Yes | Yes |
| Recent payments | Names masked | Yes | Yes |
| Top customers | Names masked | Yes | Yes |
| Revenue and goals | Total only | Yes, with CSV | Yes |
| Get told when an order is paid | No | Yes, by webhook | Set up here |
| Money | |||
| View wallet and transactions | No | Yes | Yes |
| Configure payouts and cash out | No | No | Yes |
| In game | |||
| Deliver rewards | No | Yes | View / retry |
| Take back refunded or expired rewards | No | Yes | View |
| Send a player a sign-in link | No | Yes | No |
| Read captured items | No | Yes | Yes |
| Save or delete captured items | No | Yes | No |
| Report Bedrock players and who joined | No | Yes | No |
| Keep an in-game audit log | No | Yes | View |
| Store administration | |||
| Manage coupons | No | Yes | Yes |
| Manage gift-card amounts, void a card | No | Yes | Yes |
| In-game menus | No | Read only | Yes |
| Edit products, prices, and reward commands | No | No | Yes |
| Design your storefront | No | No | Yes |
Why the catalogue is read-only everywhere but the dashboard. A caller that could name its own price could charge ₱1 for a ₱1,000 rank, and a caller that could attach its own commands could grant itself anything your server can run. Both are worth more to an attacker than any product in your store. Every order goes through a product_id you created, so the name, price, sale, description and reward commands come off your row — not off the request.
The practical consequence: set your products up in the dashboard once, then read them with GET /v1/shops/{token}/products. If you need to bulk-import hundreds of products, tell us — that is a dashboard import, not an API capability.
Why a storefront token
Why a token and not your slug? Two things a slug cannot do. It can be rotated — if someone abuses your token you regenerate it and their copy dies instantly, whereas your slug is your public address and can never change without breaking every link ever shared. And it cannot be guessed — keying this API on short public slugs would let anyone walk a dictionary and bulk-harvest every shop on the platform: catalogues, revenue figures, top buyers, all as cross-origin JSON.
The token is not a secret. It ships inside your own page’s JavaScript, the way a public storefront token does on other commerce platforms. It is an identifier you can revoke, not a password. Your slug still addresses the hosted store at /shop/your-slug, as it always has.
Server API
Everything the Storefront API sells, plus what only your server should see: what each player owns, every order, your wallet and reward delivery. Requests need a secret API key, so never put one in a browser or a game client.
Your first request
The Server API needs a server of its own (your game server’s plugin, a backend, a bot you host) because the key must stay secret. Create a key in Dashboard › Servers, one per game server, and keep it in an environment variable, for example a .env file your server loads. Never commit that file or put the key in a web page.
# .env (add it to .gitignore) KANTOPAY_API_KEY=kp_live_…
curl https://api.kantopay.com/rewards/info \ -H "Authorization: Bearer $KANTOPAY_API_KEY"
From there, sell with POST /server/checkout and deliver with /rewards/pending. The KantoPay plugin does both for you.
Authentication
Create an API key in your dashboard (each key is bound to one server) and send it on every request as either header:
Authorization: Bearer kp_live_… or X-API-Key: kp_live_…
Each endpoint is tagged with who may call it:
API key only: server-to-server operations (reward delivery, in-game payments, player lookup). Requires an API key; nothing else can call it.
Dual-auth: “read your own data” endpoints (product catalog, payments, wallet) that the dashboard also uses internally. Your API key works exactly the same way, in either header, and counts against the same per-key limit. The tag only means the endpoint is shared with the dashboard, so you call it just like the others.
So from your code the rule is simple: always send your API key. Public /pay/{link_id} status endpoints need no auth at all.
Test server keys start kp_test_. They make test orders and receive test deliveries only, and they can call the reward routes (/rewards/…), POST /payments/from-product, POST /server/quote, POST /server/checkout, and the product catalog, menu and item reads. Any other endpoint answers 403 test_key_not_allowed.
How requests work
Each endpoint shows its method (GET, POST, PUT or DELETE); all are HTTPS. Reads take their
arguments in the path (an identifier that is part of the address, like the player in
/server/players/{name}) or the query string (everything optional). Writes take
a JSON body and need Content-Type: application/json. A body sent on a GET is
ignored. An unknown query parameter is ignored rather than rejected, so adding one to a URL is never how a
request breaks.
Out-of-range numbers are clamped, not rejected. limit=9999 gives you the
maximum; limit=-5 gives you one. That is deliberate — a plugin polling in a loop should
degrade to a sensible answer rather than start failing. But a value that is not a number at all, like
limit=abc, is rejected with 400 invalid_query, because there is nothing
sensible to clamp it to.
| Parameter | Where | Default | Range |
|---|---|---|---|
limit on /server/recent-payments | query | 20 | 1–100, clamped |
limit on /server/top-customers | query | 10 | 1–100, clamped |
offset | query | 0 | 0–100000, clamped |
period | query | varies — see periods | day week month year all; anything else falls back to the default |
metric | query | spent | spent or orders |
target | query | none | any positive number; omit it and progress comes back null |
include_test | query | false | true to include test orders |
name | path | — | 1–64 chars; URL-encode it |
Paging. Ask for limit, and if has_more is true send
next_offset back as offset. has_more is measured by fetching one row
more than you asked for, so it is never a guess. Rows are ordered newest-paid first; a sale landing
mid-pagination can shift a row between pages, which is fine for a feed and not something to build an audit
on.
Types. Money is a JSON number (400.0) on the /server/*
reads (players, recent payments, top customers, goal) and on analytics. It is a string
("400.00") on the wallet, the order lists, the catalogue, /payments/from-product, and
everything shared with the Storefront API: /server/quote, /server/checkout and the
gift-card routes. Each example response shows which. Timestamps are RFC 3339 in UTC (2026-08-19T05:39:40Z). Booleans
are the literal words true and false. Player names are matched case-insensitively
and trimmed, so whatever casing your server holds will match.
Missing versus empty. Leaving an optional parameter out and sending it empty
(?period=) mean the same thing: the default applies. A missing required path segment
is not a bad request, it is a different route, and comes back 404 no_such_route.
Status codes
Read the status first to know what kind of problem you have, then the code to know
which one. These are the common statuses; each endpoint lists its own responses.
| Status | What it means here | What to do |
|---|---|---|
200 | It worked. An empty list is still a 200 — a player with no purchases is not an error. | Nothing. Check found or the array length, not the status. |
400 | The request itself is wrong: a malformed query string, an id that is not a UUID, a missing player token. | Fix the call. Retrying unchanged will fail identically. |
401 | No key, a key that is not kp_-shaped, or one that is unknown or revoked. Only a 401 means the key itself is wrong. | Check the header and the key. Do not retry — nothing about waiting will help. |
403 | A test server key (kp_test_) called something only a live key may (test_key_not_allowed). | Use a live key for it, or leave the call out on a test server. |
404 | Either the route does not exist (no_such_route) or the thing does not (product_not_found, variant_not_found, shop_not_found). Very different problems, same status. | Read the code. no_such_route is a typo in your URL; the others mean the record is gone or was never yours. |
409 | The request is fine but the state says no: a purchase limit reached, a product outside its opening hours, or the same idempotency_key still in flight. | Show message to the player. For idempotency_in_progress, wait for Retry-After and retry with the same key. |
422 | The request parsed but a value is unacceptable. Carries a field naming which one. | Show message against field. This is the only status that tells you where to look. |
429 | You are over the rate limit. | Wait for Retry-After, then retry. Do not tighten your poll loop. |
500 | Our bug, not yours. | Retry once with backoff. If it persists, quote the X-Request-ID in a ticket. |
502 | The payment provider upstream failed or timed out (provider_unavailable on /payments/from-product, with Retry-After: 5). | Retry with backoff. If it was a payment call, reuse the same idempotency_key so a retry cannot double-charge. |
503 | Temporary: the database is busy, or it could not answer while checking your key. Your key is fine. | Honour Retry-After. Treat as "later", never as "failed", and never as a reason to replace the key. |
Errors
Every failure, on every status, is the same shape. One parser handles all of them.
{ "code": "auth_invalid_api_key", "message": "Invalid or expired API key" }
field is added on 422, naming the parameter at fault. Branch on code
— never on message, which is written for humans and will be reworded.
| code | HTTP | What it means | What to do |
|---|---|---|---|
auth_missing_api_key | 401 | No credential arrived at all, on an API-key-only route (/server/*, /rewards/*). The dual-auth routes run the shared session middleware instead and answer auth_missing_token for the same mistake — branch on both if you call across the two families. | Send Authorization: Bearer kp_… or X-API-Key. If you believe you sent one, check the header name. |
auth_invalid_api_key | 401 | Wrong shape, unknown, or revoked. The message distinguishes format from validity. | Re-copy the key from the dashboard. A revoked key never comes back — make a new one. |
test_key_not_allowed | 403 | A test server key called a route only a live key may: wallet, orders, coupons, gift cards, analytics, item edits, /players/*, /audit, or /server/* other than /server/quote and /server/checkout. | Skip that call on a test server, or use a live key. |
rate_limited | 429 | Over 120 requests in 60 seconds on this key, counted across every route the key calls. | Back off for Retry-After. If you poll several servers, give each its own key. |
product_not_found / variant_not_found | 404 | POST /payments/from-product: no active product with that id in your store, or that product has no option with that variant_id. | Re-read the catalog; the product may have been deleted or switched off. |
already_owned | 400 | The player already holds this rank tier, so the upgrade would cost ₱0. | Show message to the player. Nothing was created. |
no_such_route | 404 | That path and method combination is not a route. | Check spelling and verb. The message echoes what you asked for. |
validation_error | 422 | A value is unacceptable; field names it. | Fix that field. Do not retry unchanged. |
invalid_query | 400 | The query string could not be read — usually a number parameter given a non-number. | Check the types of your query values. |
idempotency_in_progress | 409 | The same idempotency key is being processed right now. | Wait for Retry-After (2 seconds), then send the same request with the same key. Never make a new key for it: that can create a second order. |
idempotency_key_reuse | 422 | That key was already used for a different cart. | Mint a fresh key. A key is bound to the exact order it first created. |
provider_unavailable | 502 | The payment provider did not answer while POST /payments/from-product set up the payment. Nothing was created. | Retry after Retry-After (5 seconds) with the same idempotency_key. |
service_unavailable | 503 | The database is busy, or could not answer while checking your key. | Honour Retry-After and retry. Your key still works. |
internal_error | 500 | Our fault. | Retry once, then report with the X-Request-ID. |
Codes you will see in the source but never on this surface — slug_taken,
domain_taken, upload_quota_exceeded, payout_in_flight,
the ai_* family — belong to the dashboard’s own
signed-in routes. They are left out here on purpose, not by oversight.
Rate limits
120 requests per 60 seconds, per key, on every route a key can call, the dual-auth ones
included. The limit is counted before we look your key up, so
hammering with a bad key is throttled the same as a good one. Each key is separate: give every game server
its own and they will never spend each other’s budget. To refresh a whole lobby’s stats, ask for
every player in one GET /server/players?names= call rather than one call
each, so your delivery polls keep their share. The storefront half is limited differently
— per IP, per endpoint — see its own errors section.
Two headers are worth reading on every response:
| Header | On | Why you want it |
|---|---|---|
X-Request-ID | every response, refusals included | A unique id for that one request, and the id on our own log line for it. Log it. It is the fastest way for us to find what happened when you report something. Send your own (up to 128 visible characters, no ") and it is kept; anything else gets a fresh one. |
Retry-After | 429, 503, 409 idempotency_in_progress and 502 provider_unavailable | Seconds to wait. Honour it instead of guessing — a fixed retry delay either gives up too early or hammers a service that is already struggling. |
Both are listed in Access-Control-Expose-Headers, so res.headers.get('retry-after')
works from a browser too — though a browser has no business holding a kp_ key.
Reporting periods
period is a calendar window, not a rolling one. month is
“since the 1st”, not “the last 30 days” — so it resets on a boundary
rather than trailing behind you. These are the same windows the goal bar and the top-customer list on your own
storefront already use. The same window does not always mean the same total: some endpoints leave out
refunds or count customers by who received the goods, and each one says so.
period | Covers | Resets |
|---|---|---|
day | Since midnight | Every midnight |
week | Since Monday | Every Monday 00:00 |
month | Since the 1st | The 1st of each month |
year | Since 1 January | 1 January |
all | Everything, ever | Never |
Those boundaries are UTC, because that is the database’s clock. If you are in the
Philippines that is 8am your time: a day runs 8am to 8am, and a
month turns over at 8am on the 1st. Worth knowing before you wire a daily goal to a scoreboard
and wonder why it clears mid-morning.
Defaults differ, on purpose. /server/goal defaults to month,
matching the goal bar on your storefront, so a plugin that passes no period agrees with your page without
being told. /server/recent-payments and /server/top-customers default to
all, matching their storefront twins. An unrecognised value (a typo like moth)
falls back to that endpoint’s own default rather than returning an error — a
plugin polls in a loop, and a typo should not stop delivery. Every response echoes the
period it actually used, so you never have to guess which one you got.
One thing period never does: bring back refunded money. Charged-back, refunded, disputed
and voided orders are excluded from every window.
found is explicit rather than inferred from orders > 0, so “never bought” stays distinguishable from “bought nothing yet”. purchases is rolled up per product with quantities summed across orders, not one row per order. owned is keyed by cumulative_group and holds only the top tier in each (the lowest rank_order: tier 1 is the dearest) — a player who bought Diamond and then upgraded to Emerald is Emerald, not both, which is what a permissions plugin is really asking. A timed product (one that removes itself after N days) counts in owned only while its time runs: each purchase carries active (false once every purchase of it has expired; a renewal keeps it true) and expires_at (when the latest purchase runs out, null for a permanent product). Matching is case-insensitive and follows the delivered-to identity, so a gift counts for the recipient. Refunded, charged-back and test orders are excluded. For the raw receipt log instead, see /players/{name}/purchases.{
"player": "Notch",
"found": true,
"orders": 2,
"spent": 400.0,
"first_paid_at": "2026-08-18T05:39:40Z",
"last_paid_at": "2026-08-19T05:39:40Z",
"purchases": [
{ "product_id": "aaaa...", "product": "Legendary Key", "quantity": 2,
"spent": 100.0, "last_paid_at": "2026-08-19T05:39:40Z", "cumulative_group": null,
"active": true, "expires_at": null },
{ "product_id": "bbbb...", "product": "VIP Rank", "quantity": 1,
"spent": 300.0, "last_paid_at": "2026-08-18T05:39:40Z", "cumulative_group": "ranks",
"active": true, "expires_at": "2026-09-17T05:39:40Z" }
],
"owned": {
"ranks": { "product_id": "bbbb...", "product": "VIP Rank", "rank_order": 2 }
}
}names is a comma-separated list: each name is trimmed, a repeat in any casing is asked once, and at most 50 are allowed. Returns { "players": [ … ] }, one /server/players/{name} answer per name, in the order you asked; include_test works as it does there. The whole call counts once against your key’s 120 a minute. No names, more than 50, or a name over 64 characters answers 422 validation_error with field: "names".No•••) because buyers are free text with no account and never agreed to a public listing — here the caller is you, reading your own records. player is the delivered-to identity (gift recipient, else buyer): that is who to give it to. has_more is measured by fetching one extra row, not guessed. Refunded, charged-back and test orders are excluded unless you ask for test.{
"payments": [
{
"id": "1111...",
"player": "Notch",
"buyer_name": "Gifter",
"recipient_name": "Notch",
"product": "Legendary Key",
"amount": 100.0,
"method": "gcash",
"paid_at": "2026-08-19T05:39:40Z",
"is_test": false
}
],
"has_more": false,
"next_offset": null
}limit is 10 by default (1–100, clamped rather than rejected — limit=500 returns 100, not an error), so a plain call already gives you the top 10. period narrows the window and is a calendar period: ?period=month&limit=10 is your top 10 this month, resetting on the 1st.{
"customers": [
{
"player": "Notch",
"orders": 2,
"spent": 400.0,
"first_paid_at": "2026-08-18T05:39:40Z",
"last_paid_at": "2026-08-19T05:39:40Z"
}
]
}/shop/{slug}/revenue; a plugin has no page section to read the target from, so pass target and get progress back. progress is capped at 100 so a bar can render it directly, while revenue stays uncapped if you would rather say “142% of goal” yourself. Defaults to month — the same window your storefront goal bar uses — and an unrecognised value falls back to that default rather than erroring, because this sits in a poll loop. See when periods reset. Unlike the storefront source, this excludes reversals — money you refunded is not money you made.{
"period": "month",
"revenue": 400.0,
"orders": 2,
"target": 5000.0,
"progress": 8.0
}/v1/shops/{token}/quote, through the one pricing path every checkout uses — quantities, variants, cumulative tier credit, a coupon and a gift card. Read-only, like its twin./v1/shops/{token}/checkout: many products, quantities, variants, a coupon, a gift card, a gift recipient. /payments/from-product sells one product per call; this one takes the whole cart. Show the player redirect_url, the pay page’s full address (as a QR map or a clickable chat link), and poll GET /v1/orders/{id}. A test server’s key (kp_test_) makes test orders here, as it does on /payments/from-product; a live key creates a real order./v1/shops/{token}/giftcards.POST /v1/shops/{token}/giftcards: amount must be one of your denominations or inside your custom range. Same answer as its twin; the code is minted when the payment settles. Needs only the shop open, not the hosted storefront on. A test key cannot sell gift cards./v1/shops/{token}/giftcards/check. For every card you have issued at once, see /accounts/gift-cards/issued.variant_id to charge a specific variant; any active sale is applied automatically. Returns a link id, QR and pay URL, with product_id recorded on the order.It follows the web store’s rules. A rank tier is priced as an upgrade: what this player (the
recipient_name, else buyer_name) already paid into that rank ladder comes off, and a tier they already hold answers 400 already_owned. An upgrade that would cost less than the ₱1 minimum answers 400 below_minimum, as the cart does. Each command keeps its “player online”, free-slot and delay conditions, so a kit waits for the player as it would after a web purchase. Purchase limits and opening hours answer 409. An unknown product answers 404 product_not_found, an unknown variant 404 variant_not_found. If the payment provider does not answer, you get 502 provider_unavailable with Retry-After: 5; retry with the same idempotency_key. A test server key makes a test order.effective_amount), description, image and variants. Pair with POST /payments/from-product to build your own storefront.status (active, paid, expired, cancelled), from and to on when the order was made, q (a name, product, order id or payment reference) and delivery (pending, delivered, failed or none). from and to take a date (2026-10-01, a whole day in Philippine time, so to=2026-10-01 includes that day) or a full time (2026-10-01T08:00:00+08:00, with the + sent as %2B). Anything else answers 422 validation_error naming the field.{
"deliveries": [
{
"id": "...",
"link_id": "...",
"command": "/give Notch diamond_sword 1",
"sort_order": 0,
"buyer_name": "Notch",
"created_at": "2026-05-28T21:13:51Z"
}
]
}reason field tells you which: the reversal type, or "expired". Poll it exactly like /rewards/pending, run what it returns, then confirm with the same /rewards/acknowledge call: a revocation is an ordinary delivery, so per-server routing and retries behave identically. Without it a buyer who charges back keeps the rank and gets their money, and the shortfall lands on you. Commands come from the product’s removal list — one list covers both cases, because taking a rank back is the same command either way. Leave it empty and nothing is ever revoked, timed or not. A timed package sets “Remove automatically after N days” on the product. Safe to adopt at your own pace: /rewards/pending never returns a revocation, so a plugin that ignores this endpoint keeps working exactly as before. The KantoPay plugin does all of this from version 3.7.1. Earlier versions never call it, so a server still on one keeps every refunded or expired rank until it updates.{
"revocations": [
{
"id": "...",
"link_id": "...",
"command": "lp user Notch parent remove vip",
"sort_order": 0,
"player": "Notch",
"product_name": "VIP Rank",
"transaction": "...",
"reason": "chargeback",
"test_mode": false
}
],
"next_check": 30
}results, one { "id", "status", "error" } per delivery, where status is "delivered" or "failed" (blank counts as delivered; error is your own words, shown to the seller); the older delivery_ids list still works and means all delivered. A delivered command leaves the pending pool. A failed one is handed out again, until its fifth failure, when it stops and the seller can retry it from the dashboard. Any other status is refused for that id: its outcome is invalid_status, it is not written and stays pending, so a typo can never mark a reward delivered that did not run. The other ids in the batch are recorded as usual. The answer has a results list with one outcome per id, in the order sent: delivered, retrying, failed (for good), not_found (unknown, another server’s, or no longer pending), invalid_id or invalid_status. A key can only acknowledge the deliveries routed to its own server.{
"acknowledged": 1,
"failed": 1,
"message": "1 delivered, 1 failed",
"results": [
{ "id": "…", "outcome": "delivered" },
{ "id": "…", "outcome": "retrying" }
]
}{ "message": "Payment link cancelled" }; a link that is already paid, expired, or cancelled returns a 400.{
"status": "active",
"expires_at": "2026-05-29T12:00:00+00:00"
}{
"message": "Buyer name saved"
}days is 7, 30 (default), 90 or 0 for all time; anything else is 30. Everything in the answer reads the same period: revenue (gross), net after fees and tax, orders, the previous period for comparison, refunds, conversion, repeat, methods (how buyers paid), servers (what each server delivered; an order routed to several counts under each), hours and dow (Manila time), coupons, gift, the daily series (monthly for all time), and the top products and customers. Test orders and dashboard probes never count.paid_at (Manila), order_id, buyer, product, method, gross, fee, tax, net, coupon_discount, gift_card_applied, reversal. Same days as above. Written for a spreadsheet at tax time: plain numbers, no currency symbols, and a field that looks like a formula is neutralised so a sheet never runs a buyer’s name. Comes back as text/csv with a Content-Disposition filename./link in game and send them the returned url as a clickable message; it carries ?kp_player=, which your storefront reads and passes back as player_token.Why it exists:
player_name is a string anyone can type, and cumulative upgrade pricing prices against purchase history. A token is the only thing that says your game server vouched for this person. It expires after 24 hours, is signed for one shop and one player, and a token that does not verify is a 401 — never a quiet fall back to the typed name./purchases command reads, and what you check before re-delivering something lost to a world reset. Each order carries its items, because product on a cart order is the string “Cart (3 items)” and names nothing you can act on.Matched on the delivery target (the recipient for a gift, otherwise the buyer), case- and space-insensitively, and scoped to your key’s account. This is the seller-side twin of
/v1/…/players/me/orders: your key already means “I am the seller”, so no player token is needed here.Refunded, charged-back and test orders are left out, so a plugin re-delivering from this list never gives back what was refunded. Ask for them with
include_reversed=true and include_test=true; every order carries reversal_type (null, or why it was taken back) and is_test. A disputed order is still listed by default, with reversal_type: "disputed", until the dispute is settled.mode — "live", or "test" for a test server key. pending_deliveries counts the rewards waiting to be given, as /rewards/pending serves them; pending_revocations counts the removals waiting on /rewards/revocations (refunded, charged-back or expired rewards). A plugin that never polls revocations leaves that number growing. Call it once at startup so an operator sees which store and which server they are wired to, and so a plugin running against test money says so in the console instead of looking like a live install with no sales.Item catalog
A staff member holds an item, types /kantopay item import vip-sword, and the plugin captures the real ItemStack into its own items.json. These endpoints are where that file is mirrored, so your dashboard can offer the item on a product and a second server can be handed the same catalog.
Item ids are unique per store, not per server. A product belongs to the store — if vip-sword meant a different item on each of your three servers, a product pointing at it would name three different things and the dashboard could not show you what you are selling. A store spanning Minecraft versions is handled the way the format already handles it: a server on a distant version ignores the exact copy in data and rebuilds from material/name/lore, which is what data_version is for. Ids match case-insensitively, and source_key_id records which server last pushed each entry.
The server pushes; we store. An item can only be captured in-game, so the game server is the only thing that can author one and this is a mirror. That is enforced, not just described: PUT and DELETE need an API key even though GET also works from a dashboard session. There is no updated_at conflict handling because there is only ever one writer — and deleting from the dashboard would not remove anything from a server’s items.json, so the two would quietly diverge with no way back.
data is opaque. Stored and returned byte for byte, never parsed, never re-encoded. The cap is 65,536 bytes of base64 — roughly ten times a shulker box packed with named, enchanted items — so a plugin can refuse an oversized capture in-game, where the operator can still do something about it, instead of failing at upload. A store holds up to 500 items. Both numbers come back in a limits object on every /items response, so read them at startup rather than hard-coding them.
A product can deliver an item directly. Set a product’s item_id and checkout stores kantopay item give <id> <player> beside the seller’s own command templates, so it arrives as an ordinary delivery: no plugin change is needed and older builds keep working. Snapshotted there rather than read off the product at settlement, so a seller editing the product while a buyer is mid-payment cannot change what that order delivers. A product whose entire reward is an item still asks the buyer for their in-game name before showing the QR. /rewards/pending also reports item_id on the delivery, so a future build can skip parsing it back out. The generated row always carries requires_online: true and slots: 1 — an item cannot be put in an offline player’s hands, and giving it into a full inventory drops it on the floor of wherever they last stood — and it sorts after the product’s own commands, so a rank that unlocks a kit exists before the kit arrives. Buying three of something queues three gives.
data is withheld by default — 500 items at 64 KB each is a 32 MB response, and the usual caller wants the names. It comes back present and null, never silently missing, so “this item has no exact copy” (has_data: false) stays distinguishable from “you did not ask”. Pass include_data=true to seed a new server.{
"items": [
{
"id": "vip-sword",
"name": "&6VIP Sword",
"material": "DIAMOND_SWORD",
"amount": 1,
"lore": ["&7Thank you for supporting us"],
"enchants": { "DAMAGE_ALL": 5 },
"unbreakable": false,
"glint": false,
"tracked": true,
"custom_model_data": 1001,
"data": null,
"data_version": "1.21.4",
"has_data": true,
"data_bytes": 2418,
"kit": [],
"created_by": "Lonaldeu",
"created_at": "2026-09-07T13:30:51+00:00",
"updated_by": null,
"updated_at": null,
"source_key_id": "6f1c..."
}
],
"count": 1,
"include_data": false,
"limits": { "max_items": 500, "max_data_bytes": 65536 }
}data — fetching by id is how you ask for the exact copy. The id is matched case-insensitively. A miss returns 404 item_not_found, and the message names the /kantopay item import that would create it.{
"item": {
"id": "vip-sword",
"name": "&6VIP Sword",
"material": "DIAMOND_SWORD",
"data": "rO0ABXNyABpvcmcuYnVra2l0...",
"data_version": "1.21.4",
"has_data": true,
"data_bytes": 2418,
"kit": [],
"created_by": "Lonaldeu",
"created_at": "2026-09-07T13:30:51+00:00"
}
}items.json object verbatim. PUT rather than POST because the plugin already knows the id, and re-importing the same id must update one row instead of making a second. 201 on create, 200 on replace, both with {item, created}. created_by and created_at survive a replace: they record who first captured the item, which is a different question from who last changed it. A body id that disagrees with the URL is refused rather than silently resolved, and an id is rejected, never sanitised — you hold that id in your own file and will ask for it back by that exact string. Refusals: 403 item_needs_server_key (a dashboard session tried to write), 409 item_limit_reached, 422 validation_error with the field named and the real number in the message.{
"item": { "id": "vip-sword", "...": "..." },
"created": true
}409 item_in_use, and the names in the body, if anything still uses it — a product that delivers it, or another item whose kit names it (a kit missing one member hands out one item short, with only a plugin-side warning to say so). blocked_by is the total; products lists the first 25. Never a cascade and never a silent orphan. Clearing those products’ item would stop a live product delivering anything, discovered by a buyer; leaving it would point a live product at nothing, discovered by the same buyer. Naming them is the only outcome that leaves you able to act.{
"deleted": true,
"id": "vip-sword"
}
// 409 when a product still delivers it
{
"code": "item_in_use",
"field": "id",
"message": "2 product(s) or kit(s) still use \"vip-sword\": VIP Kit, starter-kit. ...",
"products": ["VIP Kit", "starter-kit"],
"blocked_by": 2
}ITEM-IMPORT, ITEM-EDIT, ITEM-DELETE, KIT-CREATE, KIT-EDIT, ITEM-GIVE, STAFF-TEST, or anything else you name. Retry freely: dedupe is your own id when you send one, else a hash of (server, at, actor, action, subject). Send an id whenever two real events could share that tuple — two gives of the same item in the same second are two gives. A bad event is rejected by index and the rest of the batch still lands, because you cannot repair a row we refuse and failing the whole batch would lose the good events with it. at is stored as sent; retention keys on our clock instead, so a wrong server clock cannot make a row immortal. A store keeps its newest 50,000 events and its last 180 days, and evicted reports anything dropped to stay under that.{
"accepted": 1,
"duplicates": 0,
"evicted": 0,
"rejected": [
{
"index": 1,
"field": "at",
"reason": "\"yesterday\" is not an RFC3339 UTC timestamp (e.g. 2026-09-07T13:30:51Z)."
}
],
"message": "1 stored, 0 already had, 1 rejected"
}Store admin
Coupons and gift-card denominations are the two things in your store an API key can create. Everything else about the catalogue stays in the dashboard, and the line between them is not arbitrary: these write a row you can see, edit and delete afterwards, at a price that is yours — they never name a price mid-checkout the way a caller inventing its own product would. A coupon made by your bot is the same object as one made by your thumb.
They exist because an integration could already sell through a coupon and never make one. If your Discord bot runs a flash sale, or your launcher hands a returning player a code, that used to mean a human in the dashboard at the exact moment it mattered.
These are live the instant they return. There is no draft state and no confirmation step — a coupon you create is redeemable by anyone who learns the code, on your store and over the Storefront API alike. Delete is equally immediate.
used_count against max_uses is the number worth watching — a coupon at its limit still exists and still lists, it just stops applying. product_ids is empty when the coupon applies to the whole cart.kind is "percent" or "fixed"; value is the number for either ("20" means 20% or ₱20 depending on the kind). max_discount caps a runaway percent coupon on a large cart and is ignored for fixed. min_amount is the subtotal it starts working at. product_ids limits it to specific products — leave it empty for the whole cart. Codes are yours to choose and must be unique on your account.GET /v1/shops/{token}/giftcards: same rows, plus the ones you have switched off.amount is what the buyer pays and what the card is worth — there is no markup or discount between the two. label is cosmetic (“Starter”, “Best value”) and optional.customMin / customMax (the range for buyer-chosen amounts, in pesos), emailNote and design (how the card looks). Gift cards never expire, so there is no expiry setting. Returns {"settings": null} if you have never set any — that is the default state, not an error.customMin cannot go below the ₱20 gift-card floor, customMax cannot be below customMin, emailNote is plain text under 300 characters, and any expiryDays is refused: gift cards never expire. design sets how the card looks in the code email, on your gift-card block and on the receipt: {"style": "color" | "image", "color": "#RRGGBB", "image": "/uploads/<file>", "title": "…", "showLogo": true}. Every field is optional; image must be a path /accounts/upload-image returned (never an outside link) and is required for the picture style; title is plain text of at most 28 characters. Unknown keys inside design are dropped, and a bad field is refused with field set to e.g. design.color.remaining_amount and status. This is the ledger you check when a player says their card did not work.409 gift_card_has_balance: every card was bought by a player, and the Gift Check Act (RA 10962) forbids taking a paid balance away. A card bought by fraud is cancelled by refunding its purchase, which KantoPay support does. Voiding an already-void card is a no-op, not an error.Webhooks
The other direction: we call your server the moment something happens to an order, so your own backend can grant a reward, post to a bot or update a database without polling. Set it up in Dashboard › Integrations › Developer webhook: paste an https:// address on your server and save. A signing secret (whsec_…) appears below it. Keep it on your server and check every request with it. Send a test event posts a signed sample to your address straight away and shows what your server answered.
Each event is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-KantoPay-Event | The event name, also in the body as event. |
X-KantoPay-Delivery | The id of this event. Every retry of it carries the same id, so dedupe on it. Also in the body as delivery_id. |
X-KantoPay-Timestamp | Unix seconds when this request was sent. A retry has a new one. |
X-KantoPay-Signature | hex(HMAC-SHA256(secret, X-KantoPay-Timestamp + "." + raw_body)), lowercase. See Signature verification. |
Events
| Event | Sent when | What to do |
|---|---|---|
sale.paid | An order is paid, including one a gift card or coupon covered in full. Test orders and Send a test event send it too, with "test": true. | Grant the order once per order_id. Grant nothing when test is true. |
order.refunded | A paid order was refunded. | Run revoke_commands to take the reward back. If reward_kept is true, KantoPay refunded the money without taking the reward back: leave it. |
order.charged_back | The buyer won a chargeback. | Run revoke_commands to take the reward back. |
order.disputed | The buyer opened a dispute. No money has moved yet. If it is lost, order.charged_back follows; if you win it, nothing more is sent. | Optional: hold the reward until it is settled. |
delivery.failed | An in-game command failed for the fifth time and will not be tried again. | Tell your staff. The command can be retried from the dashboard. |
{
"event": "sale.paid",
"order_id": "8b1f0e2c-1d3a-4c5e-9f77-2a0b6d4e1c33",
"product": "VIP Rank",
"amount": "300.00",
"net": "292.75",
"buyer": "Notch",
"recipient": "Jeb_",
"shop": "Kanto SMP",
"product_image": "https://kantopay.com/uploads/vip.png",
"paid_at": "2026-10-04T04:11:57.412345678+00:00",
"commands": ["lp user Jeb_ parent add vip"],
"items": [{ "product_id": "5d0c9a7e-2f41-4b8e-a1c3-7e6f5d4c3b2a", "name": "VIP Rank", "quantity": 1, "amount": "300.00" }],
"metadata": null,
"test": false,
"delivery_id": "c1a7f0e2-9b3d-4e6f-8a21-0d5c7b9e4f13"
}
amount is what the buyer paid and net what reaches you after fees, both pesos as strings. buyer paid; recipient is who the rewards are for (the gift’s recipient, otherwise the buyer). commands are your product’s commands with {player} already filled in with recipient and any leading / removed, ready to run; or ignore them and grant from items, the order’s lines (product_id is null for a product deleted since). metadata is what you sent with POST /payments/from-product, otherwise null. paid_at is RFC 3339 with a +00:00 offset and fractions of a second; parse it, do not slice it.
The order events carry the same order_id, product, amount, buyer, recipient, shop, items, test and delivery_id, plus reversal_type and occurred_at. Instead of commands they carry revoke_commands: your product’s removal commands, filled in the same way. The different name is on purpose, so a handler that runs commands on every event cannot grant the reward again on a refund. A full refund KantoPay made without taking the reward back sends revoke_commands: [] and reward_kept: true, so leave the reward in place. delivery.failed adds reward_delivery_id, kind (grant or revoke), command, error (what your plugin reported), attempts and server (that server key’s name), without revoke_commands.
{
"event": "order.refunded",
"order_id": "8b1f0e2c-1d3a-4c5e-9f77-2a0b6d4e1c33",
"product": "VIP Rank",
"amount": "300.00",
"buyer": "Notch",
"recipient": "Jeb_",
"shop": "Kanto SMP",
"reversal_type": "refunded",
"items": [{ "product_id": "5d0c9a7e-2f41-4b8e-a1c3-7e6f5d4c3b2a", "name": "VIP Rank", "quantity": 1, "amount": "300.00" }],
"revoke_commands": ["lp user Jeb_ parent remove vip"],
"test": false,
"occurred_at": "2026-10-05T02:30:00.000000000+00:00",
"delivery_id": "0d3b6a2e-7c41-4f8a-b5e9-1a2c3d4e5f60"
}
Signature verification
Anyone can send a request to your address, so check every one before acting on it:
1. Take X-KantoPay-Timestamp, a literal ., and the request body exactly as it arrived, before any JSON parsing. A body you parsed and turned back into text will not match.
2. Compute HMAC-SHA256 over that with your whole secret, whsec_ included, as the key, and write it as lowercase hex.
3. Compare it with X-KantoPay-Signature in constant time, and refuse a timestamp more than 5 minutes from your clock. That stops someone replaying an old request they caught.
4. Save the event (or put it on a queue), skipping any X-KantoPay-Delivery id you already have, and only then answer 2xx.
5. Do the work after answering. If you answer first and crash before saving, the event is lost: we only send it again when you do not answer 2xx.
If the KantoPay plugin already delivers your rewards in game, do not grant them again from the webhook. Use the webhook for everything else, such as a database, a bot or an email.
Signing the body alone, HMAC(secret, body), never matches: the timestamp is part of what we sign.
// Node.js with Express
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.KANTOPAY_WEBHOOK_SECRET; // the whole whsec_… value
// express.raw keeps the exact bytes we sent. Parse JSON only after the check.
app.post('/kantopay-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const ts = req.get('X-KantoPay-Timestamp') || '';
const sig = req.get('X-KantoPay-Signature') || '';
const expected = crypto.createHmac('sha256', SECRET).update(ts + '.').update(req.body).digest('hex');
const ok = sig.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
// Keep it before saying yes. saveOnce() is yours: a table with a unique key on the delivery id.
await saveOnce(req.get('X-KantoPay-Delivery'), event);
res.sendStatus(200);
// Now do the work, from what you saved (a queue worker, a job, a cron).
});
<?php
// PHP
$secret = getenv('KANTOPAY_WEBHOOK_SECRET'); // the whole whsec_… value
$body = file_get_contents('php://input'); // the raw bytes, before json_decode
$ts = $_SERVER['HTTP_X_KANTOPAY_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_KANTOPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
save_once($_SERVER['HTTP_X_KANTOPAY_DELIVERY'] ?? '', $event); // yours: unique on the delivery id
http_response_code(200); // only after it is kept
Retries and resend
Answer with a 2xx within 8 seconds. Anything else counts as a failure: another status, no answer in time, or a redirect, which is never followed. A failed event is sent again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, 6 tries in all. After the sixth it is marked failed and the store owner gets an email, at most one a day.
Delivery is at least once. A retry, or a resend, can bring you an event you already handled, so dedupe on X-KantoPay-Delivery. Events can also arrive out of order when one of them was retried; when order matters, read the order itself with GET /v1/orders/{id}. A sale that settles while our server restarts still gets its sale.paid, a few minutes late.
Your address must be https:// and on the public internet. Private and internal addresses are refused when you save and again each time we send.
Recent deliveries, under the Developer webhook in the dashboard, lists the last 50 events: whether each was sent, is waiting for its next try, or failed, with its tries, your server’s last HTTP status and the error. The store owner can Resend any of them: the same event, with the same X-KantoPay-Delivery id and body and a fresh timestamp and signature, to the address saved now. Events are kept 30 days. One whose body was not kept (sent before this log existed, or over 64 KB) cannot be resent.
Changelog
2026-10-27 — New: a sale can take a peso amount off. Products take sale_off_amount (for example "20.00": ₱100 sells for ₱80) as well as sale_percent. A product has one kind of sale at a time: sending both answers 422 validation_error on sale_off_amount, and setting one clears the other. The amount off must be less than the price; a variant cheaper than the amount off sells for ₱1. The same start and end dates apply. /v1/shops/{token}/products and /products/catalog now also return sale_off_amount and sale_off, the pesos off right now whichever kind of sale it is (amount − effective_amount, or null when not on sale). The store’s sale badge shows the cut as a percent, rounded down (₱20 off ₱150 is −13%).
2026-10-27 — Changed: a sale takes 99% off at most. Creating or updating a product with sale_percent above 99 now answers 422 validation_error on sale_percent and saves nothing; before, 100% made a ₱0 price that checkout refused. 0 still clears a sale. Products already saved at 100% are now 99% off.
2026-10-22 — New: the Server API sells everything the Storefront API sells. With your key, POST /server/quote and POST /server/checkout take a whole cart (quantities, variants, upgrade pricing, a coupon, a gift card, a gift recipient), and /server/giftcards and /server/giftcards/check list, sell and check gift cards. Same body and same answer as their Storefront API twins. A test server’s key (kp_test_) can quote and check out, and its orders are test orders. A gift-card sale also accepts buyer_email as the address the card is sent to.
2026-10-05 — Changed: gift cards, sales and "was" prices follow consumer law. POST /accounts/gift-cards/issued/{id}/void answers 409 gift_card_has_balance for a card that still has a balance (RA 10962); only a spent card can be voided. Creating or updating a product with a sale now needs sale_starts_at and sale_ends_at, no more than 3 months apart, else 422 validation_error on sale_ends_at. Stores show a sale or a compare_at_amount as a struck-through price only once the item has been offered at that price for 28 of the last 90 days; the sale price is charged either way, and /products/catalog reports on_sale and sale_percent by the same rule.
2026-10-05 — New: the KantoPay plugin takes rewards back in game. Version 3.7.1, on every platform, polls /rewards/revocations, runs the removal commands for refunds, chargebacks (a dispute you lose is one) and expired timed packages, and acknowledges them. Earlier versions never did, so update from Dashboard › Plugin. Your own integration needs nothing new.
2026-10-04 — New: webhooks are documented here, with more events, a delivery log and resend.
- The developer webhook sends
order.refunded,order.charged_back,order.disputedanddelivery.failedas well assale.paid.sale.paidgainsrecipient,items,metadata,testanddelivery_id, and itscommandsnow name the gift’s recipient, not the buyer. - Send a test event now sends a signed
sale.paidwith"test": true. It used to send"event": "test", so a handler that skipped that must now skiptest: true. Test orders sendsale.paidwith"test": truetoo. - The signature is
HMAC-SHA256(secret, X-KantoPay-Timestamp + "." + raw_body). An HMAC of the body alone never matches. See Signature verification. - The dashboard lists the last 50 events with your server’s answers, and the owner can resend any of them. An event that fails for good is emailed to the owner.
2026-10-04 — New: test orders, cancel and many players in one call.
- A test storefront token (
kps_test_) and test server keys (kp_test_) rehearse checkout, payment, settlement and delivery without real money. A test key gets403 test_key_not_allowedoutside the routes a test server needs. POST /v1/orders/{id}/cancelcancels an unpaid order, with thecancel_tokenits checkout answered with. Its gift-card, coupon and purchase-limit holds last until its QR code’s deadline, because that code can still be paid.GET /server/players?names=answers for up to 50 players in one request.
2026-10-04 — Changed: the Storefront API.
- /checkout and POST /giftcards return
redirect_url, the pay page’s full address.redirectis unchanged. - /quote returns
lines,payment_methods,free_order,is_testandblocked, the409checkout would give for a purchase limit or opening hours. - An order a 100% coupon covers is settled and delivered at checkout. It used to be refused with
400 cart_total_zero. - POST /giftcards takes
idempotency_key, and a custom amount is never under ₱20, even with a blank minimum. - GET /v1/orders/{id} adds
gift_card_code,gift_card_code_maskedanddelivery. - Categories add
id,image_url,blurbandis_ladder. Categories hidden from your store’s navigation are left out, there and on /v1/shops/{token}. - An unknown product id on /products/{id} answers
404 product_not_found, notshop_not_found.
2026-10-04 — Changed: the Server API. The items marked check can change what an existing integration sees.
- A key works in
X-API-Keyon the dual-auth routes too, and gets the same 120 requests a minute there (check: those routes had no limit). A database that cannot answer while checking a key gives503 service_unavailablewithRetry-After; it used to say401, as if the key were wrong. Refusals now carry CORS headers,X-Request-IDand a JSON content type. - /rewards/acknowledge refuses a status other than
deliveredorfailedfor that id: its outcome isinvalid_statusand it stays pending (check: such a status used to count as delivered). The rest of the batch is recorded. It now returns anoutcomeper id. - /rewards/info:
pending_deliveriescounts rewards to give only, and the newpending_revocationscounts removals waiting on /rewards/revocations. - /players/{name}/purchases leaves out refunded, charged-back and test orders unless you pass
include_reversedorinclude_test(check), and addsreversal_typeandis_test. - /server/players/{name}: a timed product whose time has run out leaves
owned, and each purchase addsactiveandexpires_at. A renewed timed rank is no longer removed by the earlier purchase’s expiry. - /payments/from-product prices rank tiers as upgrades like the web store, answers
400 already_ownedfor a tier the player holds, and keeps each command’s online, slot and delay conditions. An unknown product or variant is404 product_not_found/variant_not_found(wasauth_not_found), and a provider failure is502 provider_unavailablewithRetry-After: 5(was a500with the provider’s own text). - /payments/list filters by
fromandto. Both answered500before; a bad value is now422. - The small-order limit’s
429says inRetry-Afterhow long until the buyer can order again, not a fixed 60 seconds. - A reward meant only for servers you have since deleted, or bought before you had any server, goes to the first server that asks for it, and only to that one. It used to wait forever or run on every server.
2026-10-04 — New: send buyers back to your website. /checkout and POST /giftcards take return_url and auto_return. The receipt’s Back button goes to return_url with order_id and status; with auto_return a paid buyer goes there straight away. List your sites under Your website in the dashboard first; any other address is 400 return_url_not_allowed. GET /v1/orders/{id} now returns shop_slug, so your site can check an order is its own. Changed: a /checkout that a gift card pays in full answers 200 with redirect and order_id like every other order. It used to answer a 303 to the pay page, which a page on your own domain cannot follow.
2026-10-04 — Fixed: POST /v1/shops/{token}/giftcards sells gift cards. It used to look the token up as a shop address and answer every call 400 denomination_unavailable, whatever the amount. It now finds the shop by its token like every other Storefront API route, and a token that is not a published shop answers 404 shop_not_found. The card is emailed to recipient_email (this page used to say buyer_email, which is ignored), buyer_name is optional, and amount is in pesos. Also since 2026-10-02: refunding or charging back a gift-card purchase voids the card (a partial refund takes the refunded amount off it), and gift cards never expire, so expiryDays is no longer accepted.
2026-09-15 — Changed: /accounts/wallet/history says what each order’s commands did. Every entry tied to an order now carries execution: how many of its commands were queued (total), how many are delivered, failed and pending, how many of the pending wait for the player to join (waiting_player), and last_run_at, when the latest one was delivered (by the plugin, or by the seller pressing Mark delivered). It is null for withdrawals and for orders with nothing to run, including an order refunded before its commands ran. Every existing field is unchanged.
2026-09-08 — New: the item catalog. Capture a real Minecraft item in-game with /kantopay item import <id> and the plugin pushes it to PUT /items/{id}, where the dashboard can offer it on a product. Set a product’s item_id and settlement generates kantopay item give <id> <player> as an ordinary delivery, so nothing changes on your side — older plugin builds keep working, and /rewards/pending now also reports item_id on the delivery for a build that would rather not parse it back out. POST /audit takes the in-game half of the trail that /rewards/acknowledge never saw: who imported, edited, deleted or handed out an item, and when.
2026-09-05 — New: a reference store at example.kantopay.com. One HTML file on the Storefront API, no backend, no key, with a console that shows each request as the page makes it. Every call it uses is documented here; the page links back to each one.
2026-09-05 — New: /accounts/analytics and /accounts/analytics/export on the Server API. The dashboard’s Analytics numbers, and the period’s paid orders as CSV, for a kp_ key as well as a dashboard session. One days parameter (7, 30, 90, or 0 for all time) scopes everything in the answer.
2026-09-05 — Changed: the order floor is ₱1. min_charge on /v1/shops/{token} now reads "1"; below_minimum fires only under that. Two things replace what the old floor was quietly doing: cards are offered only from card_min_charge (₱100, their flat fee), and orders under small_order.under (₱20) share a budget of small_order.max_per_hour per buyer per shop, past which /v1/checkout answers 429 small_order_rate_limited. Both numbers are on the shop identity, so read them rather than typing them. Gift cards start at ₱20.
2026-09-05 — Changed: /accounts/wallet/history names what a gift card or coupon did. Two new entry_type values, gift_card and coupon, carry what a card covered or a code took off on an order; neither moves money. An order a card paid for in full no longer writes a zero-peso credit.
2026-09-05 — New: timed packages. Set “Remove automatically after N days” on a product and its removal commands run when the window closes, arriving on /rewards/revocations with reason: "expired". Same feed, same acknowledge, no new plumbing on your side if you already poll it. 0 days = permanent, which is every existing product.
2026-09-05 — New: /rewards/revocations takes a reward back when an order is reversed. Until now a refund or chargeback returned the buyer’s money and stopped there — they kept the rank, and the shortfall landed on the seller. Poll the new endpoint alongside /rewards/pending and confirm with the same /rewards/acknowledge. Set the commands per product under “If refunded, run these”; leave it empty and nothing is revoked. Nothing breaks if you ignore this: /rewards/pending never returns a revocation, so existing plugins are unaffected until you choose to add it.
2026-08-04 — Fixed: cart purchases now count toward cumulative upgrade pricing. An order placed through a cart — which is every /v1/checkout and every hosted-storefront checkout — recorded only the product names it contained, so the “what has this player already paid?” lookup could not see it. Two buyers who paid the same ₱500 for the same rank saw different prices for the next tier depending on which button they had pressed, and owned never went true for anything bought in a cart. Orders now record their line items; if you have historical cart orders, db/backfill_cart_items.sql recovers the single-item ones.
Also: /v1/products takes limit and offset and returns next_offset, so a catalogue over 250 products is reachable at all; /v1/checkout returns order_id; order lookups return items; new /v1/…/coupons/check and /v1/…/players/me/orders.
2026-06-12 — Per-server reward routing on API keys.
2026-06-09 — Public REST API and this reference.
KantoPay