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 startedThe 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.
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.
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 deliveryWallet & payouts
Read your live balance and a full ledger history of every fee, sale and adjustment, reconciled to the centavo.
Use WalletRefer 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.
| Action | Storefront API | Server API kp_ | Dashboard |
|---|---|---|---|
| Read products, prices, sales, variants, categories | Yes | Yes | Yes |
| Price a cart (coupons, gift cards, cumulative tiers) | Yes | — | Yes |
| Create an order / take a payment | Yes | Yes | Yes |
| Sell gift cards, check a balance | Yes | — | Yes |
| Look up an order’s status | Yes | Yes | Yes |
| Create, edit or delete a product | No | No | Yes |
| Set a price on the fly | No | No | Yes |
| Attach your own reward commands to a sale | No | No | Yes |
| Create, delete a coupon or a gift-card denomination | No | Yes — store admin | Yes |
| Menus, payout settings, storefront design | 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.
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 half | Storefront API | Server API |
|---|---|---|
| Read the catalogue, prices, sales | Yes | Also available |
| Look a player up by name — what they bought, what they own | Needs that player’s token | Yes — /server/players/{name} |
| Recent payments with real buyer names | Masked (No•••) | Yes — /server/recent-payments |
| Top customers by who received the goods | Masked, grouped by buyer | Yes — /server/top-customers |
| Revenue against a goal | Raw number only | Yes — /server/goal |
| Price a multi-item cart with coupons and gift cards | Yes | — call /v1 for this |
| Create an order | Yes, a whole cart | Yes, one product per call |
| Every order you have ever taken, with buyer names and emails | Never | Yes |
| Wallet balance and payouts | Never | Yes |
| Make a coupon or a gift-card denomination | Never | Yes — store admin |
| Reward commands to run in-game | Never | Yes — /rewards/pending |
| Commands to undo a refunded reward | Never | Yes — /rewards/revocations |
| Deliver a real captured item, not a command | Never | Yes — /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.
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.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.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. Use the items box for the JSON array.{ "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.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.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.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
/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.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.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.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-mode 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-mode orders excluded.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.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.
| 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 sandbox 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 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.
| 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. | Check the header and the key. Do not retry — nothing about waiting will help. |
404 | Either 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. |
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. | 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 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.
| 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. |
rate_limited | 429 | Over 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_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 and poll the order, do not re-send. Two identical requests raced. |
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. |
shop_unavailable | 503 | Payments are switched off for this account. | Honour Retry-After. Reward delivery keeps working. |
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,
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:
| Header | On | Why you want it |
|---|---|---|
X-Request-ID | every response | A unique id for that one request. Log it. It is the fastest way for us to find what happened when you report something. |
Retry-After | 429 and 503 | 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.
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.
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 money you refunded. Charged-back, refunded, disputed
and voided orders are excluded from every window.
/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.{
"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 }
}
}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
}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.effective_amount), description, image and variants. Pair with POST /payments/from-product to build your own storefront.{
"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.{
"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
}{
"acknowledged": 2,
"message": "Acknowledged 2 deliveries"
}{ "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.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.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 the storefront and over /v1 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), expiryDays, and emailNote. 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, expiryDays runs 1–3650, and emailNote is plain text under 300 characters.remaining_amount and status. This is the ledger you check when a player says their card did not work.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-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.
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 Docs