KantoPay Docs
Log in

KantoPay API

Take payments with GCash, Maya, QRPh and cards, then deliver in-game perks to your Minecraft server the moment an order settles, all from one REST API.

Get started

The KantoPay API is a fast, idempotent payments-and-fulfilment service built for Southeast-Asian game stores. It creates local-rail payment links, tracks them to settlement, and routes the right command to the right server, while protecting you from double-delivery and double-spend. To know where to begin, refer to Get started.

Every request is authenticated with your API key (as a Bearer token or X-API-Key header). Endpoints marked Dual-auth also work from the dashboard; API key only endpoints require a key. Game-server plugins poll /rewards/pending and confirm with /rewards/acknowledge. Poll /rewards/revocations as well to take a reward back when an order is refunded or charged back.

Features

Storefront API

Build your store’s front end yourself — your website, a Discord bot, an in-game menu — on the same catalogue, pricing and checkout the hosted store uses. The /v1 routes need no key and no signup, only your storefront token — the kps_… value, not your shop’s slug — and they answer cross-origin so a browser on your own domain can call them directly. Other platforms call this a headless API. Or skip the code entirely and embed the hosted store in an iframe.

Build your own store

Payments

Create QRPh / e-wallet payment links from the products you set up and follow them from pending to paid. List your catalog and create a payment by product_id to power a store you build yourself. All sales are one-time and final, with no subscriptions and no surprise reversals.

Use Payments

Rewards delivery

Game-server plugins poll for paid commands and acknowledge them once executed. Per-key server routing means each server only ever receives the rewards meant for it.

Use Rewards delivery

Wallet & payouts

Read your live balance and a full ledger history of every fee, sale and adjustment, reconciled to the centavo.

Use Wallet

Refer to the Server API for payments, reward delivery and wallet.

Get started

All endpoints are served from the same origin as your store, under /payments, /rewards and /accounts. To make your first call:

1. Create an API key in your dashboard. Each key is bound to one game server.
2. Send the key as a Bearer token (or X-API-Key header).
3. Set up a product in your dashboard, then create a payment with POST /payments/from-product and show the returned QR/link to your buyer, or list your products via GET /products/catalog to build your own store UI.

Storefront API

Everything under /v1 is a JSON API for building your own storefront — your website, a Discord bot, an in-game GUI — against the same catalogue, pricing and checkout the hosted store uses. Same rails, same payouts, your front end.

No key. No signup. No CORS setup. These routes take no credential at all, because everything they return is already public as HTML on your store page — and a key shipped to a browser is readable by anyone who opens devtools, so it would protect nothing. Your kp_ API key stays secret and server-side, for reward delivery.

All you need is your storefront token — one per shop, in Dashboard › Servers:

https://api.kantopay.com/v1/shops/kps_63bde17336594cbda90a0b7f5dd460d6/products

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, exactly like Tebex’s webstore token and Shopify’s storefront access token. 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.

See it running first. example.kantopay.com is a whole store — catalogue, tiered ranks, coupons, gift cards, checkout and order tracking — in one HTML file with no backend and no key, built on nothing but the calls below. Press Watch the calls on it to see every request as it happens, with a link back to the endpoint here. Paste your own storefront token into its footer to see your store in the same template.

What you can and can’t do

Read this first — it is the shape of the whole thing. Your catalogue is authored in the dashboard, always. The API sells what you built; it cannot build it.

ActionStorefront APIServer API kp_Dashboard
Read products, prices, sales, variants, categoriesYesYesYes
Price a cart (coupons, gift cards, cumulative tiers)YesYes
Create an order / take a paymentYesYesYes
Sell gift cards, check a balanceYesYes
Look up an order’s statusYesYesYes
Create, edit or delete a productNoNoYes
Set a price on the flyNoNoYes
Attach your own reward commands to a saleNoNoYes
Create, delete a coupon or a gift-card denominationNoYes — store adminYes
Menus, payout settings, storefront designNoNoYes

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.

A whole store in three calls

Read the catalogue, price the cart on the server, send the buyer to checkout. The cart itself lives in your client — there is no basket to create, no basket id to keep alive, and nothing to expire.

const SHOP = 'https://api.kantopay.com/v1/shops/kps_your_token';

// 1. What's for sale. `effective_amount` is the sale-adjusted price — show that one.
const { products } = await (await fetch(SHOP + '/products')).json();

// 2. What it costs. Always price on the server: cumulative tiers, coupons and
//    gift cards are applied here, and this is the exact path checkout uses.
const cart = [{ product_id: products[0].id, quantity: 1 }];
const quote = await (await fetch(SHOP + '/quote', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ player_name: 'Steve', items: cart, promo_code: 'LAUNCH20' })
})).json();
// -> { subtotal:"499.00", discount:"99.80", total:"399.20", coupon_ok:true, below_minimum:false, ... }

// 3. Take the money. Returns a URL — your branded pay page or a PayMongo
//    checkout, whichever you picked in Settings.
const { redirect } = await (await fetch(SHOP + '/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ player_name: 'Steve', items: cart, buyer_email: 'me@example.com' })
})).json();
window.location.href = redirect;

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 identical pricing code, so a quoted total can never disagree with what the buyer is charged. 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.

Knowing when it’s paid

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 — an order created while your account is in test mode charges no real money, and a client that can’t tell will ship real goods for play money.

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.

Which codes you can actually get depends on the endpoint, so each one lists its own. Open any endpoint below and use the response picker in its Response panel: every status it can return, with a real body for each, and what you did to earn it. That is more useful than a table you have to translate to your own call — and it cannot drift from the code the way a hand-kept list does.

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 120/min. 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.)

Don’t want our storefront at all?

You don’t have to take one. Set hosted_storefront to false and /shop/your-slug stops existing — the page, the product pages, the checkout page, the custom pages and the sitemap all 404. Your products still live in the dashboard, the Storefront API still works, payments and reward delivery are untouched.

What you still get from us in that mode: the catalogue and pricing engine, the cart maths, coupons, gift cards, the buyer’s payment page at /pay/{link_id}, payouts, and in-game delivery. What you own: every pixel a player sees before checkout.

The buyer’s payment page is still ours, and that is deliberate — it is the page that touches card details and PayMongo, so it stays on our origin under our TLS. /checkout hands you a redirect; where the buyer lands is set by your checkout provider setting, not by this flag.

Don’t want to write a front end?

The Storefront API is for people who want control. If you just want your store on your existing site — WordPress, Wix, Squarespace, Carrd — the obvious move is an iframe.

Embedding is blocked today, on purpose. Every page we serve sends X-Frame-Options: SAMEORIGIN and Content-Security-Policy: … frame-ancestors 'self', so an <iframe> on your domain will render blank. That is not an oversight: the same headers are what stop someone framing your dashboard or a buyer’s payment page invisibly under a decoy button and stealing a click.

Relaxing it safely means an allowlist you control — you name the domains permitted to embed your store, and only your storefront pages open up, never the dashboard or the pay page. If you want that, say so and we will prioritise it; it is a small change, but it is a security control and it is not going to be a blanket “anyone can frame anything”.

Meanwhile the two shipping options are: link out to your hosted store (a button on your site pointing at kantopay.com/shop/your-slug, or your own domain once you connect one), or use the Storefront API — an afternoon of work for a store indistinguishable from the rest of your site, with no framing involved at all.

Storefront API or Server API?

Slightly the wrong question, and it trips people up. /v1 is not a lower permission level than an API key — it is the absence of a credential. There is one API: half of it needs proof of who you are, half of it does not. That is the whole distinction — not two permission tiers, one API with a public half and a private half.

So a key holder can call /v1 too. Nothing stops them; those routes never look at the Authorization header. The real question is not what may this credential do — it is:

Can the machine running this code keep a secret?

Yes — your game server, your own backend, a bot process you host. Use your kp_ key for the private half, and /v1 for the public half if it is more convenient. Nothing is gained by avoiding it.

No — a browser, a game client, anything you ship to a user. /v1 only. A kp_ key in a web page hands every visitor your order history, your buyers’ email addresses and your wallet.

Which halfStorefront APIServer API
Read the catalogue, prices, salesYesAlso available
Look a player up by name — what they bought, what they ownNeeds that player’s tokenYes — /server/players/{name}
Recent payments with real buyer namesMasked (No•••)Yes — /server/recent-payments
Top customers by who received the goodsMasked, grouped by buyerYes — /server/top-customers
Revenue against a goalRaw number onlyYes — /server/goal
Price a multi-item cart with coupons and gift cardsYes— call /v1 for this
Create an orderYes, a whole cartYes, one product per call
Every order you have ever taken, with buyer names and emailsNeverYes
Wallet balance and payoutsNeverYes
Make a coupon or a gift-card denominationNeverYes — store admin
Reward commands to run in-gameNeverYes — /rewards/pending
Commands to undo a refunded rewardNeverYes — /rewards/revocations
Deliver a real captured item, not a commandNeverYes — /items

The private rows are the ones that matter. They are private not because a key unlocks extra features, but because they expose other people’s data and your money — and a credential is the only way to tell your server apart from a stranger asking the same question.

One asymmetry worth knowing: while your account is in test mode the whole /v1 surface returns 503 shop_unavailable, because the storefront is closed to the public. POST /payments/from-product keeps working and stamps the order is_test, so your server-side integration can carry on rehearsing.

They compose. The common setup is your own website for buying and the plugin on each server for delivering — the same order flows through both.

Endpoints

Every panel below is live — real requests against the real API. Each one needs your storefront token, the kps_… value that identifies your shop to /v1. It is not your slug: /v1/shops/123 returns 404 shop_not_found even when 123 is a real, published shop. Find yours in Dashboard › Servers, under the server keys — copy it and paste it into the token field. (Or fetch it with GET /accounts/shop/storefront-token while signed in.) There is exactly one per shop, and you can rotate it from that same page if it ever ends up somewhere it should not. The token is safe in a browser — it only reads what your storefront already shows the public. Your kp_ key is the opposite and must never go near one.

GET/v1/shops/{token}
Your store’s identity and the numbers a client needs before it prices anything: 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.
No credential
Parameters
token required
Response
GET/v1/shops/{token}/products
Your catalogue. Each product carries amount (list price) and effective_amount (after any running sale) — display and charge effective_amount. 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). Ordering follows the rank you set in the dashboard, so 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.
No credential
Parameters
token required
category optional query
limit optional query
offset optional query
player_name optional query
player_token optional query
Response
GET/v1/shops/{token}/products/{product_id}
One product, same shape as a row in the list. Useful for a product detail page you can link straight to. A product belonging to a different store returns product_not_found even if the id is real.
No credential
Parameters
token required
product_id required
Response
POST/v1/shops/{token}/quote
Price a cart without charging anything. Read-only: it does not redeem the coupon or draw down the gift card — that happens only when the order is paid. An invalid code comes back as coupon_ok:false with a reason, not as an error, so you can mark the field without losing the cart. Use the items box for the JSON array.
No credential
Parameters
token required
items required JSON
player_name optional
promo_code optional
gift_card_code optional
Response
POST/v1/shops/{token}/checkout
Create the order and get { "redirect": "…", "order_id": "…" } — send the buyer to redirect, and keep order_id to poll GET /v1/orders/{id} when they come back. (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 your branded /pay/ page or a PayMongo-hosted checkout, per your Settings. This creates a real order, so try it in test mode.
No credential
Parameters
token required
items required JSON
player_name required
buyer_email optional
gift_recipient optional
promo_code optional
gift_card_code optional
Response
GET/v1/orders/{id}
Order state: 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. The order id is the credential — anyone holding the link can already see this, which is why buyer_email is deliberately left out.
No credential
Parameters
id required
Response
POST/v1/shops/{token}/giftcards
Sell a gift card. amount must be one of the denominations from the GET above, or inside the custom range if you allow one. Returns { "redirect": "…", "order_id": "…" } exactly like /checkout — the card itself is minted when the payment settles, not when this returns, so poll the order rather than expecting a code here. buyer_email is where the card gets delivered; without one the buyer only ever sees it on the receipt page.
No credential
Parameters
token required
amount required
buyer_name required
buyer_email optional
Response
GET/v1/shops/{token}/giftcards/check
What is left on a card. Returns 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.
No credential
Parameters
token required
code required query
Response
GET/v1/shops/{token}/coupons/check
Is this code any good, and on what terms? Returns 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 /quote’s job — that is the only call that can know, and the only one that charges. 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.

This is the twin of /giftcards/check. Until now a client could spend a coupon and never inspect one.
No credential
Parameters
token required
code required query
Response
GET/v1/shops/{token}/players/me/orders
This player’s paid orders, newest first, each with its 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 /v1 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.
Player token
Parameters
token required
player_token required query
limit optional query
Response
GET/v1/shops/{token}/categories
Your categories with a product_count each, so you can render tabs without fetching the whole catalogue to count them. Derived from the products themselves — there is no categories table, so they have no id and sort by name.
No credential
Parameters
token required
Response
GET/v1/shops/{token}/pages
Your published custom pages — rules, FAQ, terms — so a storefront you build yourself has something to put behind those links. Returns 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.
No credential
Parameters
token required
Response
GET/v1/shops/{token}/pages/{page_slug}
One page. When 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.
No credential
Parameters
token required
page_slug required
Response
GET/v1/shops/{token}/recent-sales
The social proof your hosted store renders, so a store you build yourself isn’t missing it. Returns {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-mode orders never appear. limit 1–25, default 10.
No credential
Parameters
token required
limit optional query
Response
GET/v1/shops/{token}/top-customers
Leaderboard: {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.
No credential
Parameters
token required
period optional query
metric optional query
limit optional query
Response
GET/v1/shops/{token}/revenue
Total paid revenue for a period — the number behind a goal bar. {revenue, currency, period}, revenue a decimal string. period=day|week|month|year|all, default month. Test-mode orders excluded.
No credential
Parameters
token required
period optional query
Response
GET/v1/shops/{token}/giftcards
The denominations you sell, plus your gift-card settings (custom amount range, expiry). 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.
No credential
Parameters
token required
Response

Server API

The half that needs a key: taking payments from your own backend, delivering rewards in-game, reading your wallet and order history, and — new — managing your coupons and gift cards. Those last two used to be dashboard-only, which meant an integration could sell through a coupon but never create one. Runs on a machine you control — never in a browser. Every endpoint below shows its method, path, required credential, parameters and a copyable multi-language sample.

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. 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.

How requests work

Every Server API endpoint is a plain GET or POST over 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.

ParameterWhereDefaultRange
limit on /server/recent-paymentsquery201–100, clamped
limit on /server/top-customersquery101–100, clamped
offsetquery00–100000, clamped
periodqueryvaries — see periodsday week month year all; anything else falls back to the default
metricqueryspentspent or orders
targetquerynoneany positive number; omit it and progress comes back null
include_testqueryfalsetrue to include sandbox orders
namepath1–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 here (400.0), not a string — the older /v1 catalogue returns amounts as strings, so do not assume one shape across both halves. 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. Only these can reach a Server API caller.

StatusWhat it means hereWhat to do
200It 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.
400The 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.
401No key, a key that is not kp_-shaped, or one that is unknown or revoked.Check the header and the key. Do not retry — nothing about waiting will help.
404Either the route does not exist (no_such_route) or the thing does not (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.
422The 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.
429You are over the rate limit.Wait for Retry-After, then retry. Do not tighten your poll loop.
500Our bug, not yours.Retry once with backoff. If it persists, quote the X-Request-ID in a ticket.
502The payment provider upstream failed or timed out.Retry with backoff. If it was a payment call, reuse the same idempotency_key so a retry cannot double-charge.
503Temporary: the database is at capacity, or payments are switched off.Honour Retry-After. Treat as "later", never as "failed".

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.

codeHTTPWhat it meansWhat to do
auth_missing_api_key401No 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_key401Wrong 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.
rate_limited429Over 120 requests in 60 seconds on this key.Back off for Retry-After. If you poll several servers, give each its own key.
no_such_route404That path and method combination is not a route.Check spelling and verb. The message echoes what you asked for.
validation_error422A value is unacceptable; field names it.Fix that field. Do not retry unchanged.
invalid_query400The query string could not be read — usually a number parameter given a non-number.Check the types of your query values.
idempotency_in_progress409The same idempotency key is being processed right now.Wait and poll the order, do not re-send. Two identical requests raced.
idempotency_key_reuse422That key was already used for a different cart.Mint a fresh key. A key is bound to the exact order it first created.
shop_unavailable503Payments are switched off for this account.Honour Retry-After. Reward delivery keeps working.
internal_error500Our 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, phone_unverified, the ai_* family — belong to the dashboard’s own signed-in routes. They are left out here on purpose, not by oversight.

Rate limits and response headers

120 requests per 60 seconds, per key. 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. The storefront half is limited differently — per IP, per endpoint — see its own errors section.

Two headers are worth reading on every response:

HeaderOnWhy you want it
X-Request-IDevery responseA unique id for that one request. Log it. It is the fastest way for us to find what happened when you report something.
Retry-After429 and 503Seconds 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.

What period means, and when it resets

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, so a plugin and your shop page can never report two different numbers for the same word.

periodCoversResets
daySince midnightEvery midnight
weekSince MondayEvery Monday 00:00
monthSince the 1stThe 1st of each month
yearSince 1 January1 January
allEverything, everNever

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 money you refunded. Charged-back, refunded, disputed and voided orders are excluded from every window.

GET/server/players/{name}
What a player is to your shop, right now — the call to make on player-join. The storefront half cannot answer this: a browser can type any name into a query string, so /v1 demands that player’s own token first. Your key already proves you are the server they play on. 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. 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.
API key only
Parameters
api_key required
name required
include_test optional
Response
{
  "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 },
    { "product_id": "bbbb...", "product": "VIP Rank", "quantity": 1,
      "spent": 300.0, "last_paid_at": "2026-08-18T05:39:40Z", "cumulative_group": "ranks" }
  ],
  "owned": {
    "ranks": { "product_id": "bbbb...", "product": "VIP Rank", "rank_order": 2 }
  }
}
GET/server/recent-payments
Your own order feed, unmasked. The storefront twin runs every name through a mask (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.
API key only
Parameters
api_key required
limit optional
offset optional
period optional
include_test optional
Response
{
  "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
}
GET/server/top-customers
Your biggest supporters. Grouped on the delivered-to identity, so a player who was gifted five ranks is credited with them — the storefront twin groups on the buyer and masks the name, which is right for a public leaderboard and wrong for a plugin handing out perks. Refunded, charged-back and test orders are excluded. 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.
API key only
Parameters
api_key required
limit optional
metric optional
period optional
include_test optional
Response
{
  "customers": [
    {
      "player": "Notch",
      "orders": 2,
      "spent": 400.0,
      "first_paid_at": "2026-08-18T05:39:40Z",
      "last_paid_at": "2026-08-19T05:39:40Z"
    }
  ]
}
GET/server/goal
Revenue for a window, and progress toward a target — for an in-game goal bar, a scoreboard or a Discord status. The storefront’s goal block does this arithmetic in the browser against /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.
API key only
Parameters
api_key required
period optional
target optional
include_test optional
Response
{
  "period": "month",
  "revenue": 400.0,
  "orders": 2,
  "target": 5000.0,
  "progress": 8.0
}
POST/payments/from-product
Recommended. Create a payment from a product you set up in your dashboard. The name, price, description, delivery commands and target servers all come from the product, so the caller can’t set the price. Pass 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.
API key only
Parameters
api_key required
product_id required
variant_id optional
buyer_name optional
recipient_name optional
buyer_email optional
metadata optional JSON
Response
GET/products/catalog
List your active products so a plugin, in-game menu or custom storefront can show what’s for sale. Each entry has the product’s id, name, price (and sale-adjusted effective_amount), description, image and variants. Pair with POST /payments/from-product to build your own storefront.
Dual-auth
Parameters
api_key required
Response
GET/payments/list
List all payments with pagination.
Dual-auth
Parameters
api_key required
page optional
limit optional
Response
GET/rewards/pending
Game-server plugins poll this endpoint to receive pending command deliveries for completed payments. Returns commands that have not yet been acknowledged. Each API key belongs to one server: a key only receives the rewards routed to that server (plus any product set to deliver to all servers), so every server in your network can safely poll with its own key.
API key only
Parameters
api_key required
Response
{
  "deliveries": [
    {
      "id": "...",
      "link_id": "...",
      "command": "/give Notch diamond_sword 1",
      "sort_order": 0,
      "buyer_name": "Notch",
      "created_at": "2026-05-28T21:13:51Z"
    }
  ]
}
GET/rewards/revocations
Commands that undo a delivery — either the order was reversed (refunded, or lost to a chargeback), or it was a timed package whose window ran out. The 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.
API key only
Parameters
api_key required
player optional
Response
{
  "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
}
POST/rewards/acknowledge
Game-server plugins call this endpoint after executing commands to confirm delivery. Acknowledged commands are removed from the pending pool. A key can only acknowledge the deliveries routed to its own server.
API key only
Parameters
api_key required
delivery_ids required
Response
{
  "acknowledged": 2,
  "message": "Acknowledged 2 deliveries"
}
POST/payments/list/{id}/cancel
Cancel an active (unpaid) payment link so the buyer can no longer pay it. Returns { "message": "Payment link cancelled" }; a link that is already paid, expired, or cancelled returns a 400.
Dual-auth
Parameters
api_key required
id required
Response
GET/pay/{link_id}/status
Public status check for a payment link. Used by the buyer payment page to poll for payment completion. No authentication required.
Public — no auth required
Parameters
link_id required
Response
{
  "status": "active",
  "expires_at": "2026-05-29T12:00:00+00:00"
}
POST/pay/{link_id}/claim
Buyer sets their player name on a payment link before paying. Required for products that have commands (ranks, kits, etc.). No authentication required.
Public — no auth required
Parameters
link_id required
buyer_name required
Response
{
  "message": "Buyer name saved"
}
GET/accounts/wallet
Get current wallet balance.
Dual-auth
Parameters
api_key required
Response
GET/accounts/wallet/history
Get wallet transaction history.
Dual-auth
Parameters
api_key required
Response
GET/accounts/analytics
The numbers behind the dashboard’s Analytics page, for one period. 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.
API key or dashboard session
Parameters
api_key required
days optional
Response
GET/accounts/analytics/export
The period’s paid orders as CSV, one row per order, newest first, up to 5,000 rows: 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.
API key or dashboard session
Parameters
api_key required
days optional
Response
GET/players/{name}/purchases
Everything a player has paid for, newest first, up to 100 — what an in-game /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.
API key only
Parameters
api_key required
name required
Response
GET/rewards/info
The plugin handshake. Validates the key and answers with the store name, the server that key belongs to, the pending-delivery count, and mode"test" or "live". Call it once at startup so an operator sees which store and which server they are wired to, and so a plugin quietly running against test money says so in the console instead of looking like a live install with no sales.
API key only
Parameters
api_key required
Response

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.

GET/items
Every item this store has captured. 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.
Dual-auth
Parameters
api_key required
include_data optional
Response
{
  "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 }
}
GET/items/{id}
One item, always with 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.
Dual-auth
Parameters
api_key required
id required
Response
{
  "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"
  }
}
PUT/items/{id}
Create or replace one item — send the 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.
API key only
Parameters
api_key required
id required
body required
Response
{
  "item": { "id": "vip-sword", "...": "..." },
  "created": true
}
DELETE/items/{id}
Remove one item. Refuses with 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.
API key only
Parameters
api_key required
id required
Response
{
  "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
}
POST/audit
Record what happened in-game, in batches of up to 200. Deliveries already reach us through /rewards/acknowledge; this is the half that does not — 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.
API key only
Parameters
api_key required
body required
Response
{
  "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 the storefront and over /v1 alike. Delete is equally immediate.

GET/accounts/coupons
Every coupon on your account, active or not. 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.
API key or dashboard session
Parameters
api_key required
Response
POST/accounts/coupons
Create a coupon. 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.
API key or dashboard session
Parameters
api_key required
code required
kind required
value required
min_amount optional
max_uses optional number
max_discount optional
expires_at optional
product_ids optional JSON
Response
DELETE/accounts/coupons/{id}
Delete a coupon. Orders that already redeemed it keep their discount — this only stops future use. Deleting is the honest way to kill a leaked code; there is no disable flag to hunt for.
API key or dashboard session
Parameters
api_key required
id required
Response
GET/accounts/gift-cards/products
The fixed denominations you sell, cheapest first. This is the private twin of GET /v1/shops/{token}/giftcards: same rows, plus the ones you have switched off.
API key or dashboard session
Parameters
api_key required
Response
POST/accounts/gift-cards/products
Add a denomination. 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.
API key or dashboard session
Parameters
api_key required
amount required
label optional
Response
DELETE/accounts/gift-cards/products/{id}
Remove a denomination from sale. Cards already issued at that amount keep working — a gift card is a balance, not a pointer to this row.
API key or dashboard session
Parameters
api_key required
id required
Response
GET/accounts/gift-cards/settings
Your gift-card configuration: customMin / customMax (the range for buyer-chosen amounts), expiryDays, and emailNote. Returns {"settings": null} if you have never set any — that is the default state, not an error.
API key or dashboard session
Parameters
api_key required
Response
PUT/accounts/gift-cards/settings
Replace the whole settings object — send every key you want kept, not just the one you are changing. customMin cannot go below the ₱20 gift-card floor, customMax cannot be below customMin, expiryDays runs 1–3650, and emailNote is plain text under 300 characters.
API key or dashboard session
Parameters
api_key required
settings required JSON
Response
GET/accounts/gift-cards/issued
The last 500 cards issued on your account, newest first, with remaining_amount and status. This is the ledger you check when a player says their card did not work.
API key or dashboard session
Parameters
api_key required
Response
POST/accounts/gift-cards/issued/{id}/void
Void a card. The remaining balance stops being spendable immediately, and the buyer is not refunded by this call — voiding is for a card issued in error, or one you have refunded separately. Voiding an already-void card is a no-op, not an error.
API key or dashboard session
Parameters
api_key required
id required
Response

FAQ

Do you support subscriptions or refunds?
No. KantoPay is a one-time-purchase model and all sales are final, which keeps fulfilment simple and fees predictable.

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.

Changelog

2026-09-08New: 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-05New: 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-05New: /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-05Changed: 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-05Changed: /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-05New: 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-05New: /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-04Fixed: 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.

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.

KantoPay · Payments API · Rust · PostgreSQL