KantoPay Docs
Dashboard

KantoPay API

Accept payments and deliver in-game rewards.

Not sure which you need? Compare the two APIs.

FAQ

Do you support subscriptions?
Not yet. Subscriptions are coming; for now every purchase is a one-time payment.

Can a paid order be reversed?
Yes. KantoPay can refund it (a store asks KantoPay support; stores can’t refund from the dashboard or the API), and a buyer can win a chargeback. The order’s reversal_type says which, and /rewards/revocations or the order.refunded event tells your server what to take back.

How do I make sure each server only gets its own rewards?
Each API key is bound to a single server. A key only receives deliveries routed to that server (plus any product set to deliver to all servers), so every server in your network polls safely with its own key.

What happens if my plugin polls twice?
Delivery is idempotent. A command stays in the pending pool until acknowledged, and acknowledging is safe to retry.

Glossary

QRPh — the Philippine national QR standard; one QR pays from any participating bank or e-wallet.
Payment link — a hosted checkout page (and QR) for a single order, tracked from pending to paid.
Reward / command — the in-game action delivered to a server when an order settles.
Idempotent — safe to call more than once; repeats don’t double-charge or double-deliver.

Storefront API

Build your own store on the same catalogue, pricing and checkout as the hosted store: a website, a Discord bot or an in-game menu. Requests need no key, only your public storefront token (kps_…) from Dashboard › Servers.

It works from a static site: plain HTML and JavaScript on GitHub Pages, Netlify, Cloudflare Pages or any web host, with no server of your own.

No secret API key is needed. Two actions take a token of their own: a player’s orders need that player’s player_token (from /players/link), and cancelling an order needs the cancel_token its checkout returned.

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

See one running at example.kantopay.com, a whole store in one HTML file. Why a token and not your slug? · Capabilities

Quick start

Read the catalogue, price the cart, send the buyer to checkout. You need a published product and your storefront token; use your test token (kps_test_…) while you build, so no real money moves. Pick a language: none of them needs a library or a download.

# bash, macOS, Linux or Git Bash. Use your test token while you build.
SHOP=https://api.kantopay.com/v1/shops/kps_test_your_token

# 1. What's for sale. Copy a product id; show effective_amount (the price after any sale).
curl -sS $SHOP/products

# 2. Price the cart. Send the same cart to checkout so both price the same thing.
CART='"player_name":"Steve","items":[{"product_id":"PRODUCT_ID","quantity":1}]'
curl -sS -X POST $SHOP/quote -H 'Content-Type: application/json' -d "{$CART}"

# 3. Take the money. Send the buyer to redirect_url; keep order_id.
curl -sS -X POST $SHOP/checkout -H 'Content-Type: application/json' \
  -d "{$CART,\"buyer_email\":\"me@example.com\",\"idempotency_key\":\"order-1\"}"

# Later: is it paid?
curl -sS https://api.kantopay.com/v1/orders/ORDER_ID// Run as <script type="module">. Use your test token while you build.
const SHOP = 'https://api.kantopay.com/v1/shops/kps_test_your_token';

async function call(path, body) {
  const res = await fetch(SHOP + path, body ? {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  } : undefined);
  // Every error is { code, message }. Branch on code; show message.
  const data = await res.json()
    .catch(() => ({ code: 'bad_response', message: `HTTP ${res.status}` }));
  if (!res.ok) {
    const err = new Error(data.message);
    throw Object.assign(err, { code: data.code, status: res.status });
  }
  return data;
}

// 1. What's for sale. Show effective_amount (the price after any sale).
const { products } = await call('/products');
if (!products.length) throw new Error('Publish a product in the dashboard first.');

// 2. One cart for the quote AND the checkout, so both price the same thing.
const cart = {
  player_name: 'Steve',
  items: [{ product_id: products[0].id, quantity: 1 }],
  promo_code: 'LAUNCH20',                        // optional
};
const quote = await call('/quote', cart);
// A bad coupon is an answer, not an error: show it and drop it.
if (cart.promo_code && !quote.coupon_ok) {
  console.warn(quote.coupon_message);
  delete cart.promo_code;
}
// blocked: a purchase limit or opening hours. Checkout would refuse too.
if (quote.blocked) throw new Error(quote.blocked.message);
if (quote.below_minimum) throw new Error(`Orders start at ₱${quote.min_charge}.`);
console.log(`Total: ₱${quote.total}`);   // put this on your Pay button

// 3. Take the money. One idempotency key per purchase: a double click makes one order.
const order = await call('/checkout', {
  ...cart,
  buyer_email: 'me@example.com',
  idempotency_key: crypto.randomUUID(),
});
// Keep the order id to check when the buyer comes back, then go to the pay page.
sessionStorage.setItem('kp_order', order.order_id);
location.href = order.redirect_url;
// Back on your site: GET /v1/orders/{order_id} says when it is paid.# Python 3, standard library only. Use your test token while you build.
import json, uuid, urllib.request, urllib.error

SHOP = 'https://api.kantopay.com/v1/shops/kps_test_your_token'

def call(path, body=None):
    req = urllib.request.Request(
        SHOP + path,
        data=json.dumps(body).encode() if body else None,
        headers={'Content-Type': 'application/json'},
    )
    try:
        with urllib.request.urlopen(req) as res:
            return json.load(res)
    except urllib.error.HTTPError as e:
        err = json.load(e)                      # every error is { code, message }
        raise RuntimeError(f"{err['code']}: {err['message']}")

# 1. What's for sale. Show effective_amount (the price after any sale).
products = call('/products')['products']
if not products:
    raise SystemExit('Publish a product in the dashboard first.')

# 2. One cart for the quote AND the checkout, so both price the same thing.
cart = {
    'player_name': 'Steve',
    'items': [{'product_id': products[0]['id'], 'quantity': 1}],
    'promo_code': 'LAUNCH20',                   # optional
}
quote = call('/quote', cart)
if cart.get('promo_code') and not quote['coupon_ok']:
    print(quote['coupon_message'])              # a bad coupon is an answer: show it, drop it
    del cart['promo_code']
if quote['blocked']:
    raise SystemExit(quote['blocked']['message'])
if quote['below_minimum']:
    raise SystemExit(f"Orders start at {quote['min_charge']}.")
print('Total:', quote['total'])                 # money is a string: '69.00'

# 3. Take the money. One idempotency key per purchase.
order = call('/checkout', {**cart, 'buyer_email': 'me@example.com',
                           'idempotency_key': str(uuid.uuid4())})
print('Pay here:', order['redirect_url'])       # send the buyer this link
print('Order:', order['order_id'])              # GET /v1/orders/{order_id} says when it is paid<?php
// PHP 7.4+, no libraries. Use your test token while you build.
const SHOP = 'https://api.kantopay.com/v1/shops/kps_test_your_token';

function call(string $path, ?array $body = null): array {
    $ctx = stream_context_create(['http' => [
        'method'        => $body ? 'POST' : 'GET',
        'header'        => "Content-Type: application/json\r\n",
        'content'       => $body ? json_encode($body) : '',
        'ignore_errors' => true,                // read error bodies too
    ]]);
    $data = json_decode(file_get_contents(SHOP . $path, false, $ctx), true);
    preg_match('{HTTP/\S+ (\d+)}', $http_response_header[0], $m);
    if ($m[1] >= 400) {                         // every error is { code, message }
        throw new RuntimeException("{$data['code']}: {$data['message']}");
    }
    return $data;
}

// 1. What's for sale. Show effective_amount (the price after any sale).
$products = call('/products')['products'];
if (!$products) exit("Publish a product in the dashboard first.\n");

// 2. One cart for the quote AND the checkout, so both price the same thing.
$cart = [
    'player_name' => 'Steve',
    'items'       => [['product_id' => $products[0]['id'], 'quantity' => 1]],
    'promo_code'  => 'LAUNCH20',                // optional
];
$quote = call('/quote', $cart);
if (isset($cart['promo_code']) && !$quote['coupon_ok']) {
    echo $quote['coupon_message'], "\n";        // a bad coupon is an answer: show it, drop it
    unset($cart['promo_code']);
}
if ($quote['blocked']) exit($quote['blocked']['message'] . "\n");
if ($quote['below_minimum']) exit("Orders start at ₱{$quote['min_charge']}.\n");
echo "Total: ₱{$quote['total']}\n";

// 3. Take the money. One idempotency key per purchase.
$order = call('/checkout', $cart + [
    'buyer_email'     => 'me@example.com',
    'idempotency_key' => bin2hex(random_bytes(16)),
]);
echo "Pay here: {$order['redirect_url']}\n";    // send the buyer this link
echo "Order: {$order['order_id']}\n";           // GET /v1/orders/{order_id} says when it is paid
Before you ship: money, verified players, tiered prices, quotes

Money is always a string — "499.00", never 499.0. A float cannot hold ₱0.10 exactly, and a storefront that rounds is a storefront that loses money. Parse with a decimal library, or keep it as a string until you print it.

Prove who the player is. player_name is an unverified string — anyone can type anyone’s username. To prove it, your game server calls POST /players/link with its API key when a player types /link, and sends them the returned URL as a clickable message. Your storefront reads ?kp_player= off that URL and passes it back as player_token on /products, /quote and /checkout. A verified token replaces the typed name for pricing and delivery, so the discount and the goods always land on the same person. Sending no token still works exactly as before; sending one that doesn’t verify is 401 invalid_player_token, never a quiet fall back to the typed name.

Tiered products need a player name. If a product has a cumulative_group, its price depends on who is buying — an upgrade costs the difference, not the full tier. Pass player_name to /products to render the grid correctly, and note that only one product per group may be in a cart at a time. Personalised requests are throttled on the same budget as /quote, because the prices reveal what that player has already spent on the ladder.

Quote before you charge. /quote and /checkout run the same pricing code, so the same cart prices the same way. A quote is not a promise: if a sale ends, a coupon runs out or the player buys elsewhere in between, checkout prices the cart again and charges the new total, so show the total on the pay page as final. It also tells you below_minimum before the buyer hits the ₱1 floor, so you can disable your Pay button instead of failing at the last step. Cards are offered only from ₱100 (card_min_charge on /v1/shops/{token}); below that, checkout returns the other methods. The quote also returns lines (what each line costs after upgrade pricing, adding up to subtotal), payment_methods (what checkout will offer for that total), free_order (a coupon or gift card covers everything, so checkout settles the order at once with no payment step) and blocked: null, or the {code, message} checkout would refuse with as a 409 — a purchase limit reached, or a product outside its opening hours. Show blocked.message and keep Pay disabled.

Payment status

Poll GET /v1/orders/{id} until it reports paid. Check reversal_type too: an order can be paid and later refunded or charged back, and if you grant entitlements on paid you need to know when to revoke them. Check is_test as well — a test order charges no real money, and a client that can’t tell will ship real goods for play money.

Test orders

Rehearse the whole flow without real money: checkout, payment, settlement and delivery. You need two things from Dashboard › Servers, and neither exists until you make it.

  • A test storefront token (kps_test_…). Use it in place of your token. Every Storefront API read answers as usual, and GET /v1/shops/{token} says "is_test": true. Checkouts and gift cards made with it are test orders, at the real price.
  • A test server, whose key starts kp_test_. Put that key in the plugin on a test server. It receives the test orders’ deliveries and nothing else, and its in-game purchases (POST /payments/from-product and POST /server/checkout) are test orders too. It can’t reach your wallet, orders, coupons or gift cards.

A test order is paid on PayMongo’s test system: the payment page has a button that completes the test payment. When it settles, your balance, payouts and sales figures don’t change, no coupon use or gift card is spent, no gift card is made, the buyer gets no email, and nothing is posted to Discord. Your developer webhook gets sale.paid with "test": true, and each delivery carries test_mode: true. Your live servers never receive a test order. Test orders are deleted 30 minutes after they are made.

Return URLs

After paying, a buyer lands on our receipt at /pay/{id}. To bring them back to your own site, list it under Your website in the dashboard (up to five https:// addresses; your verified custom domain counts without being listed), then send return_url with /checkout or POST /giftcards.

body: JSON.stringify({
  player_name: 'Steve', items: cart,
  return_url: 'https://store.example.com/thanks',
  auto_return: true
})
// After paying, the buyer arrives at
// https://store.example.com/thanks?order_id=9f1e6c1a-…&status=paid

The receipt’s Back to store button goes to return_url with order_id and status added to its query; any query it already has is kept. status is paid, or expired / cancelled when the order ended unpaid. With auto_return: true a paid buyer skips the receipt, unless it shows something they must not miss: a gift card’s code, or the button that gets their Discord role.

An address that is not https, or not on one of your websites, is refused with 400 return_url_not_allowed before any order is made, so a typo shows up on your first test order.

The same buyer checking out the same cart again soon after (within ten minutes, while the first order is unpaid) gets that first order back, with the return_url it was made with. Test a new return_url with a new cart or player name.

Don’t trust the query. Anyone can type ?status=paid into your address bar, or paste the order_id of an older order or of someone else’s receipt. Keep the order_id that /checkout returned with the buyer’s session, and accept only that one, once. Then read GET /v1/orders/{order_id} and check that status is paid, amount is what you expected and is_test is false. shop_slug is only a sanity check that the order is your store’s.

Errors and rate limits

Every failure, on every status, is the same two fields. You need exactly one parser.

{ "code": "cart_too_large", "message": "A cart can hold at most 50 different items." }

Branch on code; show message to the buyer. A 422 adds a third field, field, naming the parameter at fault.

Each endpoint lists its own responses. Use the response picker on an endpoint to see each status it documents, with an example body and when it happens.

Response headers. Retry-After, X-Request-ID and Idempotent-Replay are exposed to browsers via Access-Control-Expose-Headers, so res.headers.get('retry-after') works from a page. X-Request-ID is unique per request — log it, and quote it if you ever open a support ticket.

Rate limits here are per IP, and per endpoint: quote 60/min, checkout 30/min, gift-card purchase 20/min, order lookup and cancel 120/min together. Debounce your quote calls — a quote on every keystroke will hit the cap. (The Server API is limited differently: 120/min per key. See its own limits.)

API-only mode

To run only your own front end, turn off the hosted storefront (hosted_storefront: false). Your /shop/your-slug pages then return 404; products, the Storefront API, payments and delivery keep working.

The payment page stays on KantoPay: /checkout returns its address as redirect_url, and return_url brings the buyer back to your site.

Embedding

Store pages can’t be shown in an iframe: they send X-Frame-Options: SAMEORIGIN, which also protects the dashboard and the payment page. Link to your hosted store instead (kantopay.com/shop/your-slug, or your own domain), or build your pages with the Storefront API.

Endpoints

Each endpoint sends real requests from Try it. Paste your storefront token (kps_…, not your slug) into its token field.

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, leaving out any you hid from your store’s navigation. is_test is true when {token} is your test token.
No API key
Parameters
token required
Response
GET/v1/shops/{token}/products
Your catalogue. Each product carries amount (list price) and effective_amount (after any running sale): show effective_amount as the catalogue price. With player_name, show that player your_price instead (below). What is actually charged is the total from /quote and checkout. Variants carry both too, and inherit the parent’s sale. cumulative_group names the upgrade ladder a product sits on, or is null. Pass category to filter. has_more is measured, not guessed — and when it is true, next_offset tells you the number to send back as offset for the next page. Pass limit to page in smaller chunks (clamped to 1–250; an out-of-range value is clamped, not rejected). Products come in the order your dashboard lays them out: category by category in your category order (Uncategorised last), then by position inside each, newest first on a tie. Reordering products mid-page-through can repeat or skip one — fine for a shop listing, not for anything that has to be exhaustive.

Pass player_name and every product also gets your_price and owned — the price that player pays, with credit for what they already own on the ladder. Without it a tiered catalogue shows the wrong number to anyone mid-upgrade: Emerald lists at ₱899, but a player who already bought Diamond pays ₱400. owned:true means they have the tier already and the cart will refuse it, so render it as owned rather than as a free item.
No API key
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 API key
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. Besides the totals it returns lines, payment_methods, free_order, is_test and blocked, the refusal checkout would give (see Quote before you charge). Use the items box for the JSON array.
No API key
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": "…", "redirect_url": "…", "order_id": "…" } — send the buyer to redirect_url, the pay page’s full address (redirect is the same page as a path on our host, /pay/{id}, which a page on your own domain cannot use as it is), and keep order_id to poll GET /v1/orders/{id} when they come back. A new order also carries cancel_token: keep it if the buyer may cancel, since only this answer has it. (The id used to be reachable only by pulling it out of the redirect URL, which meant hardcoding the shape of a URL we are free to change.) Same body as /quote, but player_name is required, and this one really does redeem the coupon and draw the gift card. The target is the buyer’s /pay/ page; an order a gift card or a 100% coupon pays in full (or the two together) is settled and delivered on the spot and answers the same JSON, its page already the receipt. Send an idempotency_key so a double-tapped Pay makes one order: a repeat with the same key and cart gets the first order back with Idempotent-Replay: true. return_url (an https address on one of your websites, up to 500 characters) is where the buyer goes back to afterwards, with order_id and status added; auto_return: true sends a paid buyer there without stopping at the receipt. See Send buyers back to your website. This creates a real order, so try it with your test token.
No API key
Parameters
token required
items required JSON
player_name required
buyer_email optional
gift_recipient optional
promo_code optional
gift_card_code optional
return_url optional
auto_return optional true/false
idempotency_key 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. shop_slug names the store the order belongs to: any order id resolves here, so a site reading order_id off its return URL checks it is its own before trusting the order. The order id is the credential — anyone holding the link can already see this, which is why buyer_email is deliberately left out.

For a receipt page: gift_card_code is the code a paid gift-card order bought (null otherwise, and until payment settles). It shows in full for 30 minutes after payment, then only its first and last four characters, with gift_card_code_masked: true, the same rule as our own receipt; the recipient’s copy went by email. delivery is {total, delivered, waiting_online, failed}: how many in-game reward commands the order has, how many ran, how many wait for the player to join, and how many failed. All zero before payment.
No API key
Parameters
id required
Response
POST/v1/orders/{id}/cancel
The buyer walked away from an unpaid order. Send the order’s cancel_token as the X-Cancel-Token header or ?token=. Only the checkout or gift-card buy that made the order answers with it, so keep it if the buyer may cancel; without it the answer is 404 order_not_found, as for an id that does not exist. Orders made from the dashboard, the API, in game or on Discord have no token: the store cancels those. Cancelling frees the order’s idempotency_key, so the same purchase can be made again as a new order. The order’s gift-card balance, coupon use and purchase-limit units stay held until its QR code’s deadline, 30 minutes from checkout, because that QR code can still be paid. Returns { "id": "…", "status": "cancelled" }. Safe to repeat: a cancelled or expired order answers 200 with its status as it stands. A paid order answers 409 order_paid. A few older orders also have a checkout page at the payment provider; that page is closed first, and if it cannot be closed just then the order stays and the answer is 409 checkout_open, so try again. A payment that still arrives for the cancelled order’s QR code is recorded and delivered as normal. No body. Shares 120 calls a minute per IP with the read above.
Cancel token
Parameters
id required
token required
Response
POST/v1/shops/{token}/giftcards
Sell a gift card. amount is in pesos, as a string ("500" or "500.00", never centavos), and must be one of the denominations from the GET below, or inside the custom range if you allow one; a custom amount is never under ₱20, even when you left the minimum blank. Returns { "redirect": "…", "redirect_url": "…", "order_id": "…" } exactly like /checkout — the card itself is minted when the payment settles, not when this returns, so poll GET /v1/orders/{id} and read its gift_card_code rather than expecting a code here. Send an idempotency_key, one per purchase the buyer means to make, and reuse it for every retry and double tap of that purchase, so they make one order: the repeat gets the first order back with Idempotent-Replay: true. Without one, every call makes a new order. recipient_email is where the card gets delivered; without one the buyer only ever sees it on the receipt page. buyer_name is optional and shows on the order. return_url and auto_return work as on /checkout, except that auto_return has no effect: the receipt with the card’s code always shows, and its Back button goes to your return_url. A token that is not an open shop answers 404 shop_not_found; an amount that is not on sale answers 400 denomination_unavailable; a return_url that is not on one of your websites answers 400 return_url_not_allowed.
No API key
Parameters
token required
amount required
buyer_name optional
recipient_email optional
return_url optional
auto_return optional true/false
idempotency_key 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 API key
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 what /quote calculates; checkout then creates the order. A rejected code is a 200 with valid:false and a reason you can show the buyer, for the same reason /quote reports it as a field: a mistyped code is an answer, not a broken integration.

The gift-card twin is /giftcards/check.
No API key
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 the Storefront API an unverified name is allowed, because the worst it does is quote someone their own upgrade price. Here it would hand any visitor the purchase history of any player whose username they can guess. Mint one with POST /players/link after your server has vouched for them.

Orders gifted to this player appear with gifted:true — matched on the same identity that earns cumulative upgrade credit, which is also the identity they were priced against.
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, in the order you set in the dashboard, then by name. Each also carries what you gave it there: id, image_url, blurb and is_ladder (its products are tiers of one rank ladder). A category you never decorated still lists, with id, image_url and blurb null and is_ladder false. A category hidden from your store’s navigation is left out, as it is on your hosted store.
No API key
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 API key
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 API key
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 orders never appear. limit 1–25, default 10.
No API key
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 API key
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 orders excluded.
No API key
Parameters
token required
period optional query
Response
GET/v1/shops/{token}/giftcards
The denominations you sell (each amount in pesos, as a string), plus your gift-card settings (the custom amount range). Gift cards never expire. POST the same path with amount, buyer_name and recipient_email to sell one; GET /giftcards/check?code= returns a balance. The smallest card is ₱20.
No API key
Parameters
token required
Response

Which API to use

Use the Storefront API in anything players can see, such as a website. Use the Server API on machines you control: your game server or your own backend.

Choosing an API

There is one API with a public half and a private half. The Storefront API is every path under /v1/shops/ and /v1/orders/; it needs no credential, and a key holder can call it too. The Server API is everything else (/server, /rewards, /payments, /accounts, /items, /players) and needs your key. What decides which you use is where your code runs.

Can the machine running this code keep a secret?

Yes (your game server, your backend, a bot you host): use your kp_ key, kept in an environment variable such as a .env file.

No (a static site, a browser, a game client, anything you ship to players): use the Storefront API only. It needs no backend. A kp_ key in a web page hands every visitor your orders, your buyers’ emails and your wallet.

Most stores use both: a website that sells through the Storefront API and the plugin on each server that delivers with a key. Both have test orders.

Capabilities

The Server API does everything the Storefront API does, and more: what each player owns and what has expired, every order with real buyer names, your wallet, reward delivery, and coupon and gift-card admin. Products, prices and reward commands are set in the dashboard; neither API can change them.

ActionStorefront APIServer APIDashboard
Run from a static site, no backendYesNo: needs a server that keeps the key in .env—
Browse productsYesYesYes
Price carts and create ordersYesYesYes
View player purchasesPlayer’s own linkBy player nameYes
Deliver rewardsNoYesView / retry
View wallet and transactionsNoYesYes
Manage coupons and gift cardsNoYesYes
Edit products, prices, and reward commandsNoNoYes
Configure payouts and cash outNoNoYes
View all capabilities
ActionStorefront APIServer APIDashboard
Buying
Browse products, prices, sales and categoriesYesYesYes
Read your pages and category artworkYesUse the Storefront APIYes
Price a cart (coupons, gift cards, upgrade tiers)YesYesYes
Create an orderYesYesYes
Buy a gift card, check its balanceYesYesYes
Check a coupon codeYesYesYes
Cancel an unpaid orderWith its cancel tokenAny unpaid orderYes
Make test ordersTest tokenTest server keyYes
Orders and players
Look up an orderBy order idAll orders, searchableYes
See what a player has paid forPlayer’s own linkBy player nameYes
See what a player owns now, and what expiredNoYesOrder list only
Read a player’s receipts, refunds includedNoYesYes
Recent paymentsNames maskedYesYes
Top customersNames maskedYesYes
Revenue and goalsTotal onlyYes, with CSVYes
Get told when an order is paidNoYes, by webhookSet up here
Money
View wallet and transactionsNoYesYes
Configure payouts and cash outNoNoYes
In game
Deliver rewardsNoYesView / retry
Take back refunded or expired rewardsNoYesView
Send a player a sign-in linkNoYesNo
Read captured itemsNoYesYes
Save or delete captured itemsNoYesNo
Report Bedrock players and who joinedNoYesNo
Keep an in-game audit logNoYesView
Store administration
Manage couponsNoYesYes
Manage gift-card amounts, void a cardNoYesYes
In-game menusNoRead onlyYes
Edit products, prices, and reward commandsNoNoYes
Design your storefrontNoNoYes

Why the catalogue is read-only everywhere but the dashboard. A caller that could name its own price could charge ₱1 for a ₱1,000 rank, and a caller that could attach its own commands could grant itself anything your server can run. Both are worth more to an attacker than any product in your store. Every order goes through a product_id you created, so the name, price, sale, description and reward commands come off your row — not off the request.

The practical consequence: set your products up in the dashboard once, then read them with GET /v1/shops/{token}/products. If you need to bulk-import hundreds of products, tell us — that is a dashboard import, not an API capability.

Why a storefront token

Why a token and not your slug? Two things a slug cannot do. It can be rotated — if someone abuses your token you regenerate it and their copy dies instantly, whereas your slug is your public address and can never change without breaking every link ever shared. And it cannot be guessed — keying this API on short public slugs would let anyone walk a dictionary and bulk-harvest every shop on the platform: catalogues, revenue figures, top buyers, all as cross-origin JSON.

The token is not a secret. It ships inside your own page’s JavaScript, the way a public storefront token does on other commerce platforms. It is an identifier you can revoke, not a password. Your slug still addresses the hosted store at /shop/your-slug, as it always has.

Server API

Everything the Storefront API sells, plus what only your server should see: what each player owns, every order, your wallet and reward delivery. Requests need a secret API key, so never put one in a browser or a game client.

Your first request

The Server API needs a server of its own (your game server’s plugin, a backend, a bot you host) because the key must stay secret. Create a key in Dashboard › Servers, one per game server, and keep it in an environment variable, for example a .env file your server loads. Never commit that file or put the key in a web page.

# .env  (add it to .gitignore)
KANTOPAY_API_KEY=kp_live_…
curl https://api.kantopay.com/rewards/info \
  -H "Authorization: Bearer $KANTOPAY_API_KEY"

From there, sell with POST /server/checkout and deliver with /rewards/pending. The KantoPay plugin does both for you.

Authentication

Create an API key in your dashboard (each key is bound to one server) and send it on every request as either header:

Authorization: Bearer kp_live_…  or  X-API-Key: kp_live_…

Each endpoint is tagged with who may call it:

API key only: server-to-server operations (reward delivery, in-game payments, player lookup). Requires an API key; nothing else can call it.
Dual-auth: “read your own data” endpoints (product catalog, payments, wallet) that the dashboard also uses internally. Your API key works exactly the same way, in either header, and counts against the same per-key limit. The tag only means the endpoint is shared with the dashboard, so you call it just like the others.

So from your code the rule is simple: always send your API key. Public /pay/{link_id} status endpoints need no auth at all.

Test server keys start kp_test_. They make test orders and receive test deliveries only, and they can call the reward routes (/rewards/…), POST /payments/from-product, POST /server/quote, POST /server/checkout, and the product catalog, menu and item reads. Any other endpoint answers 403 test_key_not_allowed.

How requests work

Each endpoint shows its method (GET, POST, PUT or DELETE); all are HTTPS. Reads take their arguments in the path (an identifier that is part of the address, like the player in /server/players/{name}) or the query string (everything optional). Writes take a JSON body and need Content-Type: application/json. A body sent on a GET is ignored. An unknown query parameter is ignored rather than rejected, so adding one to a URL is never how a request breaks.

Out-of-range numbers are clamped, not rejected. limit=9999 gives you the maximum; limit=-5 gives you one. That is deliberate — a plugin polling in a loop should degrade to a sensible answer rather than start failing. But a value that is not a number at all, like limit=abc, is rejected with 400 invalid_query, because there is nothing sensible to clamp it to.

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 test orders
namepath—1–64 chars; URL-encode it

Paging. Ask for limit, and if has_more is true send next_offset back as offset. has_more is measured by fetching one row more than you asked for, so it is never a guess. Rows are ordered newest-paid first; a sale landing mid-pagination can shift a row between pages, which is fine for a feed and not something to build an audit on.

Types. Money is a JSON number (400.0) on the /server/* reads (players, recent payments, top customers, goal) and on analytics. It is a string ("400.00") on the wallet, the order lists, the catalogue, /payments/from-product, and everything shared with the Storefront API: /server/quote, /server/checkout and the gift-card routes. Each example response shows which. Timestamps are RFC 3339 in UTC (2026-08-19T05:39:40Z). Booleans are the literal words true and false. Player names are matched case-insensitively and trimmed, so whatever casing your server holds will match.

Missing versus empty. Leaving an optional parameter out and sending it empty (?period=) mean the same thing: the default applies. A missing required path segment is not a bad request, it is a different route, and comes back 404 no_such_route.

Status codes

Read the status first to know what kind of problem you have, then the code to know which one. These are the common statuses; each endpoint lists its own responses.

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. Only a 401 means the key itself is wrong.Check the header and the key. Do not retry — nothing about waiting will help.
403A test server key (kp_test_) called something only a live key may (test_key_not_allowed).Use a live key for it, or leave the call out on a test server.
404Either the route does not exist (no_such_route) or the thing does not (product_not_found, variant_not_found, shop_not_found). Very different problems, same status.Read the code. no_such_route is a typo in your URL; the others mean the record is gone or was never yours.
409The request is fine but the state says no: a purchase limit reached, a product outside its opening hours, or the same idempotency_key still in flight.Show message to the player. For idempotency_in_progress, wait for Retry-After and retry with the same key.
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 (provider_unavailable on /payments/from-product, with Retry-After: 5).Retry with backoff. If it was a payment call, reuse the same idempotency_key so a retry cannot double-charge.
503Temporary: the database is busy, or it could not answer while checking your key. Your key is fine.Honour Retry-After. Treat as "later", never as "failed", and never as a reason to replace the key.

Errors

Every failure, on every status, is the same shape. One parser handles all of them.

{ "code": "auth_invalid_api_key", "message": "Invalid or expired API key" }

field is added on 422, naming the parameter at fault. Branch on code — never on message, which is written for humans and will be reworded.

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.
test_key_not_allowed403A test server key called a route only a live key may: wallet, orders, coupons, gift cards, analytics, item edits, /players/*, /audit, or /server/* other than /server/quote and /server/checkout.Skip that call on a test server, or use a live key.
rate_limited429Over 120 requests in 60 seconds on this key, counted across every route the key calls.Back off for Retry-After. If you poll several servers, give each its own key.
product_not_found / variant_not_found404POST /payments/from-product: no active product with that id in your store, or that product has no option with that variant_id.Re-read the catalog; the product may have been deleted or switched off.
already_owned400The player already holds this rank tier, so the upgrade would cost ₱0.Show message to the player. Nothing was created.
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 for Retry-After (2 seconds), then send the same request with the same key. Never make a new key for it: that can create a second order.
idempotency_key_reuse422That key was already used for a different cart.Mint a fresh key. A key is bound to the exact order it first created.
provider_unavailable502The payment provider did not answer while POST /payments/from-product set up the payment. Nothing was created.Retry after Retry-After (5 seconds) with the same idempotency_key.
service_unavailable503The database is busy, or could not answer while checking your key.Honour Retry-After and retry. Your key still works.
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, the ai_* family — belong to the dashboard’s own signed-in routes. They are left out here on purpose, not by oversight.

Rate limits

120 requests per 60 seconds, per key, on every route a key can call, the dual-auth ones included. The limit is counted before we look your key up, so hammering with a bad key is throttled the same as a good one. Each key is separate: give every game server its own and they will never spend each other’s budget. To refresh a whole lobby’s stats, ask for every player in one GET /server/players?names= call rather than one call each, so your delivery polls keep their share. The storefront half is limited differently — per IP, per endpoint — see its own errors section.

Two headers are worth reading on every response:

HeaderOnWhy you want it
X-Request-IDevery response, refusals includedA unique id for that one request, and the id on our own log line for it. Log it. It is the fastest way for us to find what happened when you report something. Send your own (up to 128 visible characters, no ") and it is kept; anything else gets a fresh one.
Retry-After429, 503, 409 idempotency_in_progress and 502 provider_unavailableSeconds to wait. Honour it instead of guessing — a fixed retry delay either gives up too early or hammers a service that is already struggling.

Both are listed in Access-Control-Expose-Headers, so res.headers.get('retry-after') works from a browser too — though a browser has no business holding a kp_ key.

Reporting periods

period is a calendar window, not a rolling one. month is “since the 1st”, not “the last 30 days” — so it resets on a boundary rather than trailing behind you. These are the same windows the goal bar and the top-customer list on your own storefront already use. The same window does not always mean the same total: some endpoints leave out refunds or count customers by who received the goods, and each one says so.

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 refunded money. 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 the Storefront API 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. A timed product (one that removes itself after N days) counts in owned only while its time runs: each purchase carries active (false once every purchase of it has expired; a renewal keeps it true) and expires_at (when the latest purchase runs out, null for a permanent product). Matching is case-insensitive and follows the delivered-to identity, so a gift counts for the recipient. Refunded, charged-back and test orders are excluded. For the raw receipt log instead, see /players/{name}/purchases.
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,
      "active": true, "expires_at": null },
    { "product_id": "bbbb...", "product": "VIP Rank", "quantity": 1,
      "spent": 300.0, "last_paid_at": "2026-08-18T05:39:40Z", "cumulative_group": "ranks",
      "active": true, "expires_at": "2026-09-17T05:39:40Z" }
  ],
  "owned": {
    "ranks": { "product_id": "bbbb...", "product": "VIP Rank", "rank_order": 2 }
  }
}
GET/server/players
The same answer for many players in one request, for a server refreshing everyone online. names is a comma-separated list: each name is trimmed, a repeat in any casing is asked once, and at most 50 are allowed. Returns { "players": [ … ] }, one /server/players/{name} answer per name, in the order you asked; include_test works as it does there. The whole call counts once against your key’s 120 a minute. No names, more than 50, or a name over 64 characters answers 422 validation_error with field: "names".
API key only
Parameters
api_key required
names required
include_test optional
Response
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/server/quote
The storefront’s cart total, with your key instead of the public token: same body and same answer as /v1/shops/{token}/quote, through the one pricing path every checkout uses — quantities, variants, cumulative tier credit, a coupon and a gift card. Read-only, like its twin.
API key only
Parameters
api_key required
items required JSON
player_name optional
promo_code optional
gift_card_code optional
Response
POST/server/checkout
Create a whole-cart order, with the same body and the same answer as /v1/shops/{token}/checkout: many products, quantities, variants, a coupon, a gift card, a gift recipient. /payments/from-product sells one product per call; this one takes the whole cart. Show the player redirect_url, the pay page’s full address (as a QR map or a clickable chat link), and poll GET /v1/orders/{id}. A test server’s key (kp_test_) makes test orders here, as it does on /payments/from-product; a live key creates a real order.
API key only
Parameters
api_key required
items required JSON
player_name required
buyer_email optional
gift_recipient optional
promo_code optional
gift_card_code optional
Response
GET/server/giftcards
The gift-card denominations you sell and your custom-amount range, as /v1/shops/{token}/giftcards.
API key only
Parameters
api_key required
Response
POST/server/giftcards
Sell a gift card, as POST /v1/shops/{token}/giftcards: amount must be one of your denominations or inside your custom range. Same answer as its twin; the code is minted when the payment settles. Needs only the shop open, not the hosted storefront on. A test key cannot sell gift cards.
API key only
Parameters
api_key required
amount required
buyer_name optional
buyer_email optional
Response
GET/server/giftcards/check
A gift-card code’s balance, as /v1/shops/{token}/giftcards/check. For every card you have issued at once, see /accounts/gift-cards/issued.
API key only
Parameters
api_key required
code required
Response
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.

It follows the web store’s rules. A rank tier is priced as an upgrade: what this player (the recipient_name, else buyer_name) already paid into that rank ladder comes off, and a tier they already hold answers 400 already_owned. An upgrade that would cost less than the ₱1 minimum answers 400 below_minimum, as the cart does. Each command keeps its “player online”, free-slot and delay conditions, so a kit waits for the player as it would after a web purchase. Purchase limits and opening hours answer 409. An unknown product answers 404 product_not_found, an unknown variant 404 variant_not_found. If the payment provider does not answer, you get 502 provider_unavailable with Retry-After: 5; retry with the same idempotency_key. A test server key makes a test order.
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, newest first. Filters, all optional and combinable: status (active, paid, expired, cancelled), from and to on when the order was made, q (a name, product, order id or payment reference) and delivery (pending, delivered, failed or none). from and to take a date (2026-10-01, a whole day in Philippine time, so to=2026-10-01 includes that day) or a full time (2026-10-01T08:00:00+08:00, with the + sent as %2B). Anything else answers 422 validation_error naming the field.
Dual-auth
Parameters
api_key required
page optional
limit optional
status optional
from optional
to 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. The KantoPay plugin does all of this from version 3.7.1. Earlier versions never call it, so a server still on one keeps every refunded or expired rank until it updates.
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 report how each one went. Send results, one { "id", "status", "error" } per delivery, where status is "delivered" or "failed" (blank counts as delivered; error is your own words, shown to the seller); the older delivery_ids list still works and means all delivered. A delivered command leaves the pending pool. A failed one is handed out again, until its fifth failure, when it stops and the seller can retry it from the dashboard. Any other status is refused for that id: its outcome is invalid_status, it is not written and stays pending, so a typo can never mark a reward delivered that did not run. The other ids in the batch are recorded as usual. The answer has a results list with one outcome per id, in the order sent: delivered, retrying, failed (for good), not_found (unknown, another server’s, or no longer pending), invalid_id or invalid_status. A key can only acknowledge the deliveries routed to its own server.
API key only
Parameters
api_key required
results optional JSON
delivery_ids optional
Response
{
  "acknowledged": 1,
  "failed": 1,
  "message": "1 delivered, 1 failed",
  "results": [
    { "id": "…", "outcome": "delivered" },
    { "id": "…", "outcome": "retrying" }
  ]
}
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.

Refunded, charged-back and test orders are left out, so a plugin re-delivering from this list never gives back what was refunded. Ask for them with include_reversed=true and include_test=true; every order carries reversal_type (null, or why it was taken back) and is_test. A disputed order is still listed by default, with reversal_type: "disputed", until the dispute is settled.
API key only
Parameters
api_key required
name required
include_reversed optional
include_test optional
Response
GET/rewards/info
The plugin handshake. Validates the key and answers with the store name, the server that key belongs to, what is waiting for it, and mode — "live", or "test" for a test server key. pending_deliveries counts the rewards waiting to be given, as /rewards/pending serves them; pending_revocations counts the removals waiting on /rewards/revocations (refunded, charged-back or expired rewards). A plugin that never polls revocations leaves that number growing. Call it once at startup so an operator sees which store and which server they are wired to, and so a plugin running against test money says so in the console instead of looking like a live install with no sales.
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 your store and over the Storefront API 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, in pesos), emailNote and design (how the card looks). Gift cards never expire, so there is no expiry setting. Returns {"settings": null} if you have never set any — that is the default state, not an error.
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, emailNote is plain text under 300 characters, and any expiryDays is refused: gift cards never expire. design sets how the card looks in the code email, on your gift-card block and on the receipt: {"style": "color" | "image", "color": "#RRGGBB", "image": "/uploads/<file>", "title": "…", "showLogo": true}. Every field is optional; image must be a path /accounts/upload-image returned (never an outside link) and is required for the picture style; title is plain text of at most 28 characters. Unknown keys inside design are dropped, and a bad field is refused with field set to e.g. design.color.
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 that has been spent. A card with a balance answers 409 gift_card_has_balance: every card was bought by a player, and the Gift Check Act (RA 10962) forbids taking a paid balance away. A card bought by fraud is cancelled by refunding its purchase, which KantoPay support does. Voiding an already-void card is a no-op, not an error.
API key or dashboard session
Parameters
api_key required
id required
Response

Webhooks

The other direction: we call your server the moment something happens to an order, so your own backend can grant a reward, post to a bot or update a database without polling. Set it up in Dashboard › Integrations › Developer webhook: paste an https:// address on your server and save. A signing secret (whsec_…) appears below it. Keep it on your server and check every request with it. Send a test event posts a signed sample to your address straight away and shows what your server answered.

Each event is a POST with a JSON body and these headers:

HeaderValue
X-KantoPay-EventThe event name, also in the body as event.
X-KantoPay-DeliveryThe id of this event. Every retry of it carries the same id, so dedupe on it. Also in the body as delivery_id.
X-KantoPay-TimestampUnix seconds when this request was sent. A retry has a new one.
X-KantoPay-Signaturehex(HMAC-SHA256(secret, X-KantoPay-Timestamp + "." + raw_body)), lowercase. See Signature verification.

Events

EventSent whenWhat to do
sale.paidAn order is paid, including one a gift card or coupon covered in full. Test orders and Send a test event send it too, with "test": true.Grant the order once per order_id. Grant nothing when test is true.
order.refundedA paid order was refunded.Run revoke_commands to take the reward back. If reward_kept is true, KantoPay refunded the money without taking the reward back: leave it.
order.charged_backThe buyer won a chargeback.Run revoke_commands to take the reward back.
order.disputedThe buyer opened a dispute. No money has moved yet. If it is lost, order.charged_back follows; if you win it, nothing more is sent.Optional: hold the reward until it is settled.
delivery.failedAn in-game command failed for the fifth time and will not be tried again.Tell your staff. The command can be retried from the dashboard.
{
  "event": "sale.paid",
  "order_id": "8b1f0e2c-1d3a-4c5e-9f77-2a0b6d4e1c33",
  "product": "VIP Rank",
  "amount": "300.00",
  "net": "292.75",
  "buyer": "Notch",
  "recipient": "Jeb_",
  "shop": "Kanto SMP",
  "product_image": "https://kantopay.com/uploads/vip.png",
  "paid_at": "2026-10-04T04:11:57.412345678+00:00",
  "commands": ["lp user Jeb_ parent add vip"],
  "items": [{ "product_id": "5d0c9a7e-2f41-4b8e-a1c3-7e6f5d4c3b2a", "name": "VIP Rank", "quantity": 1, "amount": "300.00" }],
  "metadata": null,
  "test": false,
  "delivery_id": "c1a7f0e2-9b3d-4e6f-8a21-0d5c7b9e4f13"
}

amount is what the buyer paid and net what reaches you after fees, both pesos as strings. buyer paid; recipient is who the rewards are for (the gift’s recipient, otherwise the buyer). commands are your product’s commands with {player} already filled in with recipient and any leading / removed, ready to run; or ignore them and grant from items, the order’s lines (product_id is null for a product deleted since). metadata is what you sent with POST /payments/from-product, otherwise null. paid_at is RFC 3339 with a +00:00 offset and fractions of a second; parse it, do not slice it.

The order events carry the same order_id, product, amount, buyer, recipient, shop, items, test and delivery_id, plus reversal_type and occurred_at. Instead of commands they carry revoke_commands: your product’s removal commands, filled in the same way. The different name is on purpose, so a handler that runs commands on every event cannot grant the reward again on a refund. A full refund KantoPay made without taking the reward back sends revoke_commands: [] and reward_kept: true, so leave the reward in place. delivery.failed adds reward_delivery_id, kind (grant or revoke), command, error (what your plugin reported), attempts and server (that server key’s name), without revoke_commands.

{
  "event": "order.refunded",
  "order_id": "8b1f0e2c-1d3a-4c5e-9f77-2a0b6d4e1c33",
  "product": "VIP Rank",
  "amount": "300.00",
  "buyer": "Notch",
  "recipient": "Jeb_",
  "shop": "Kanto SMP",
  "reversal_type": "refunded",
  "items": [{ "product_id": "5d0c9a7e-2f41-4b8e-a1c3-7e6f5d4c3b2a", "name": "VIP Rank", "quantity": 1, "amount": "300.00" }],
  "revoke_commands": ["lp user Jeb_ parent remove vip"],
  "test": false,
  "occurred_at": "2026-10-05T02:30:00.000000000+00:00",
  "delivery_id": "0d3b6a2e-7c41-4f8a-b5e9-1a2c3d4e5f60"
}

Signature verification

Anyone can send a request to your address, so check every one before acting on it:

1. Take X-KantoPay-Timestamp, a literal ., and the request body exactly as it arrived, before any JSON parsing. A body you parsed and turned back into text will not match.
2. Compute HMAC-SHA256 over that with your whole secret, whsec_ included, as the key, and write it as lowercase hex.
3. Compare it with X-KantoPay-Signature in constant time, and refuse a timestamp more than 5 minutes from your clock. That stops someone replaying an old request they caught.
4. Save the event (or put it on a queue), skipping any X-KantoPay-Delivery id you already have, and only then answer 2xx.
5. Do the work after answering. If you answer first and crash before saving, the event is lost: we only send it again when you do not answer 2xx.

If the KantoPay plugin already delivers your rewards in game, do not grant them again from the webhook. Use the webhook for everything else, such as a database, a bot or an email.

Signing the body alone, HMAC(secret, body), never matches: the timestamp is part of what we sign.

// Node.js with Express
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.KANTOPAY_WEBHOOK_SECRET; // the whole whsec_… value

// express.raw keeps the exact bytes we sent. Parse JSON only after the check.
app.post('/kantopay-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const ts = req.get('X-KantoPay-Timestamp') || '';
  const sig = req.get('X-KantoPay-Signature') || '';
  const expected = crypto.createHmac('sha256', SECRET).update(ts + '.').update(req.body).digest('hex');
  const ok = sig.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // Keep it before saying yes. saveOnce() is yours: a table with a unique key on the delivery id.
  await saveOnce(req.get('X-KantoPay-Delivery'), event);
  res.sendStatus(200);
  // Now do the work, from what you saved (a queue worker, a job, a cron).
});
<?php
// PHP
$secret = getenv('KANTOPAY_WEBHOOK_SECRET');      // the whole whsec_… value
$body   = file_get_contents('php://input');       // the raw bytes, before json_decode
$ts     = $_SERVER['HTTP_X_KANTOPAY_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_X_KANTOPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
save_once($_SERVER['HTTP_X_KANTOPAY_DELIVERY'] ?? '', $event);   // yours: unique on the delivery id
http_response_code(200);                                         // only after it is kept

Retries and resend

Answer with a 2xx within 8 seconds. Anything else counts as a failure: another status, no answer in time, or a redirect, which is never followed. A failed event is sent again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, 6 tries in all. After the sixth it is marked failed and the store owner gets an email, at most one a day.

Delivery is at least once. A retry, or a resend, can bring you an event you already handled, so dedupe on X-KantoPay-Delivery. Events can also arrive out of order when one of them was retried; when order matters, read the order itself with GET /v1/orders/{id}. A sale that settles while our server restarts still gets its sale.paid, a few minutes late.

Your address must be https:// and on the public internet. Private and internal addresses are refused when you save and again each time we send.

Recent deliveries, under the Developer webhook in the dashboard, lists the last 50 events: whether each was sent, is waiting for its next try, or failed, with its tries, your server’s last HTTP status and the error. The store owner can Resend any of them: the same event, with the same X-KantoPay-Delivery id and body and a fresh timestamp and signature, to the address saved now. Events are kept 30 days. One whose body was not kept (sent before this log existed, or over 64 KB) cannot be resent.

Changelog

2026-10-27 — New: a sale can take a peso amount off. Products take sale_off_amount (for example "20.00": ₱100 sells for ₱80) as well as sale_percent. A product has one kind of sale at a time: sending both answers 422 validation_error on sale_off_amount, and setting one clears the other. The amount off must be less than the price; a variant cheaper than the amount off sells for ₱1. The same start and end dates apply. /v1/shops/{token}/products and /products/catalog now also return sale_off_amount and sale_off, the pesos off right now whichever kind of sale it is (amount − effective_amount, or null when not on sale). The store’s sale badge shows the cut as a percent, rounded down (₱20 off ₱150 is −13%).

2026-10-27 — Changed: a sale takes 99% off at most. Creating or updating a product with sale_percent above 99 now answers 422 validation_error on sale_percent and saves nothing; before, 100% made a ₱0 price that checkout refused. 0 still clears a sale. Products already saved at 100% are now 99% off.

2026-10-22 — New: the Server API sells everything the Storefront API sells. With your key, POST /server/quote and POST /server/checkout take a whole cart (quantities, variants, upgrade pricing, a coupon, a gift card, a gift recipient), and /server/giftcards and /server/giftcards/check list, sell and check gift cards. Same body and same answer as their Storefront API twins. A test server’s key (kp_test_) can quote and check out, and its orders are test orders. A gift-card sale also accepts buyer_email as the address the card is sent to.

2026-10-05 — Changed: gift cards, sales and "was" prices follow consumer law. POST /accounts/gift-cards/issued/{id}/void answers 409 gift_card_has_balance for a card that still has a balance (RA 10962); only a spent card can be voided. Creating or updating a product with a sale now needs sale_starts_at and sale_ends_at, no more than 3 months apart, else 422 validation_error on sale_ends_at. Stores show a sale or a compare_at_amount as a struck-through price only once the item has been offered at that price for 28 of the last 90 days; the sale price is charged either way, and /products/catalog reports on_sale and sale_percent by the same rule.

2026-10-05 — New: the KantoPay plugin takes rewards back in game. Version 3.7.1, on every platform, polls /rewards/revocations, runs the removal commands for refunds, chargebacks (a dispute you lose is one) and expired timed packages, and acknowledges them. Earlier versions never did, so update from Dashboard › Plugin. Your own integration needs nothing new.

2026-10-04 — New: webhooks are documented here, with more events, a delivery log and resend.

  • The developer webhook sends order.refunded, order.charged_back, order.disputed and delivery.failed as well as sale.paid. sale.paid gains recipient, items, metadata, test and delivery_id, and its commands now name the gift’s recipient, not the buyer.
  • Send a test event now sends a signed sale.paid with "test": true. It used to send "event": "test", so a handler that skipped that must now skip test: true. Test orders send sale.paid with "test": true too.
  • The signature is HMAC-SHA256(secret, X-KantoPay-Timestamp + "." + raw_body). An HMAC of the body alone never matches. See Signature verification.
  • The dashboard lists the last 50 events with your server’s answers, and the owner can resend any of them. An event that fails for good is emailed to the owner.

2026-10-04 — New: test orders, cancel and many players in one call.

  • A test storefront token (kps_test_) and test server keys (kp_test_) rehearse checkout, payment, settlement and delivery without real money. A test key gets 403 test_key_not_allowed outside the routes a test server needs.
  • POST /v1/orders/{id}/cancel cancels an unpaid order, with the cancel_token its checkout answered with. Its gift-card, coupon and purchase-limit holds last until its QR code’s deadline, because that code can still be paid.
  • GET /server/players?names= answers for up to 50 players in one request.

2026-10-04 — Changed: the Storefront API.

  • /checkout and POST /giftcards return redirect_url, the pay page’s full address. redirect is unchanged.
  • /quote returns lines, payment_methods, free_order, is_test and blocked, the 409 checkout would give for a purchase limit or opening hours.
  • An order a 100% coupon covers is settled and delivered at checkout. It used to be refused with 400 cart_total_zero.
  • POST /giftcards takes idempotency_key, and a custom amount is never under ₱20, even with a blank minimum.
  • GET /v1/orders/{id} adds gift_card_code, gift_card_code_masked and delivery.
  • Categories add id, image_url, blurb and is_ladder. Categories hidden from your store’s navigation are left out, there and on /v1/shops/{token}.
  • An unknown product id on /products/{id} answers 404 product_not_found, not shop_not_found.

2026-10-04 — Changed: the Server API. The items marked check can change what an existing integration sees.

  • A key works in X-API-Key on the dual-auth routes too, and gets the same 120 requests a minute there (check: those routes had no limit). A database that cannot answer while checking a key gives 503 service_unavailable with Retry-After; it used to say 401, as if the key were wrong. Refusals now carry CORS headers, X-Request-ID and a JSON content type.
  • /rewards/acknowledge refuses a status other than delivered or failed for that id: its outcome is invalid_status and it stays pending (check: such a status used to count as delivered). The rest of the batch is recorded. It now returns an outcome per id.
  • /rewards/info: pending_deliveries counts rewards to give only, and the new pending_revocations counts removals waiting on /rewards/revocations.
  • /players/{name}/purchases leaves out refunded, charged-back and test orders unless you pass include_reversed or include_test (check), and adds reversal_type and is_test.
  • /server/players/{name}: a timed product whose time has run out leaves owned, and each purchase adds active and expires_at. A renewed timed rank is no longer removed by the earlier purchase’s expiry.
  • /payments/from-product prices rank tiers as upgrades like the web store, answers 400 already_owned for a tier the player holds, and keeps each command’s online, slot and delay conditions. An unknown product or variant is 404 product_not_found / variant_not_found (was auth_not_found), and a provider failure is 502 provider_unavailable with Retry-After: 5 (was a 500 with the provider’s own text).
  • /payments/list filters by from and to. Both answered 500 before; a bad value is now 422.
  • The small-order limit’s 429 says in Retry-After how long until the buyer can order again, not a fixed 60 seconds.
  • A reward meant only for servers you have since deleted, or bought before you had any server, goes to the first server that asks for it, and only to that one. It used to wait forever or run on every server.

2026-10-04 — New: send buyers back to your website. /checkout and POST /giftcards take return_url and auto_return. The receipt’s Back button goes to return_url with order_id and status; with auto_return a paid buyer goes there straight away. List your sites under Your website in the dashboard first; any other address is 400 return_url_not_allowed. GET /v1/orders/{id} now returns shop_slug, so your site can check an order is its own. Changed: a /checkout that a gift card pays in full answers 200 with redirect and order_id like every other order. It used to answer a 303 to the pay page, which a page on your own domain cannot follow.

2026-10-04 — Fixed: POST /v1/shops/{token}/giftcards sells gift cards. It used to look the token up as a shop address and answer every call 400 denomination_unavailable, whatever the amount. It now finds the shop by its token like every other Storefront API route, and a token that is not a published shop answers 404 shop_not_found. The card is emailed to recipient_email (this page used to say buyer_email, which is ignored), buyer_name is optional, and amount is in pesos. Also since 2026-10-02: refunding or charging back a gift-card purchase voids the card (a partial refund takes the refunded amount off it), and gift cards never expire, so expiryDays is no longer accepted.

2026-09-15 — Changed: /accounts/wallet/history says what each order’s commands did. Every entry tied to an order now carries execution: how many of its commands were queued (total), how many are delivered, failed and pending, how many of the pending wait for the player to join (waiting_player), and last_run_at, when the latest one was delivered (by the plugin, or by the seller pressing Mark delivered). It is null for withdrawals and for orders with nothing to run, including an order refunded before its commands ran. Every existing field is unchanged.

2026-09-08 — New: the item catalog. Capture a real Minecraft item in-game with /kantopay item import <id> and the plugin pushes it to PUT /items/{id}, where the dashboard can offer it on a product. Set a product’s item_id and settlement generates kantopay item give <id> <player> as an ordinary delivery, so nothing changes on your side — older plugin builds keep working, and /rewards/pending now also reports item_id on the delivery for a build that would rather not parse it back out. POST /audit takes the in-game half of the trail that /rewards/acknowledge never saw: who imported, edited, deleted or handed out an item, and when.

2026-09-05 — New: a reference store at example.kantopay.com. One HTML file on the Storefront API, no backend, no key, with a console that shows each request as the page makes it. Every call it uses is documented here; the page links back to each one.

2026-09-05 — New: /accounts/analytics and /accounts/analytics/export on the Server API. The dashboard’s Analytics numbers, and the period’s paid orders as CSV, for a kp_ key as well as a dashboard session. One days parameter (7, 30, 90, or 0 for all time) scopes everything in the answer.

2026-09-05 — Changed: the order floor is ₱1. min_charge on /v1/shops/{token} now reads "1"; below_minimum fires only under that. Two things replace what the old floor was quietly doing: cards are offered only from card_min_charge (₱100, their flat fee), and orders under small_order.under (₱20) share a budget of small_order.max_per_hour per buyer per shop, past which /v1/checkout answers 429 small_order_rate_limited. Both numbers are on the shop identity, so read them rather than typing them. Gift cards start at ₱20.

2026-09-05 — Changed: /accounts/wallet/history names what a gift card or coupon did. Two new entry_type values, gift_card and coupon, carry what a card covered or a code took off on an order; neither moves money. An order a card paid for in full no longer writes a zero-peso credit.

2026-09-05 — New: timed packages. Set “Remove automatically after N days” on a product and its removal commands run when the window closes, arriving on /rewards/revocations with reason: "expired". Same feed, same acknowledge, no new plumbing on your side if you already poll it. 0 days = permanent, which is every existing product.

2026-09-05 — New: /rewards/revocations takes a reward back when an order is reversed. Until now a refund or chargeback returned the buyer’s money and stopped there — they kept the rank, and the shortfall landed on the seller. Poll the new endpoint alongside /rewards/pending and confirm with the same /rewards/acknowledge. Set the commands per product under “If refunded, run these”; leave it empty and nothing is revoked. Nothing breaks if you ignore this: /rewards/pending never returns a revocation, so existing plugins are unaffected until you choose to add it.

2026-08-04 — Fixed: cart purchases now count toward cumulative upgrade pricing. An order placed through a cart — which is every /v1/checkout and every hosted-storefront checkout — recorded only the product names it contained, so the “what has this player already paid?” lookup could not see it. Two buyers who paid the same ₱500 for the same rank saw different prices for the next tier depending on which button they had pressed, and owned never went true for anything bought in a cart. Orders now record their line items; if you have historical cart orders, db/backfill_cart_items.sql recovers the single-item ones.
Also: /v1/products takes limit and offset and returns next_offset, so a catalogue over 250 products is reachable at all; /v1/checkout returns order_id; order lookups return items; new /v1/…/coupons/check and /v1/…/players/me/orders.

2026-06-12 — Per-server reward routing on API keys.

2026-06-09 — Public REST API and this reference.