# KingPay integration guide

> Integration contract for developers and AI coding assistants.
> Reviewed against this repository on 2026-09-19.
> Scope: the project-based Merchant API. The anonymous Core API is a separate interface.
> This guide describes current implementation, including limitations; do not invent missing endpoints or silently assume all integration modes behave alike.

## Contents

1. [Configuration and authentication](#1-configuration-and-authentication)
2. [Choose an integration](#2-choose-an-integration)
3. [Hosted checkout](#3-hosted-checkout)
4. [Payment lifecycle and accounting](#4-payment-lifecycle-and-accounting)
5. [Merchant webhooks](#5-merchant-webhooks)
6. [Verify signatures](#6-verify-signatures)
7. [Deposits](#7-deposits)
8. [Custom payment addresses](#8-custom-payment-addresses)
9. [Subscriptions](#9-subscriptions)
10. [Currencies, networks and fees](#10-currencies-networks-and-fees)
11. [Errors, retries and reconciliation](#11-errors-retries-and-reconciliation)
12. [Implementation checklist](#12-implementation-checklist)
13. [Current limitations and maintenance](#13-current-limitations-and-maintenance)

## 1. Configuration and authentication

Create a KingPay account and project. Configure payout addresses, enable accepted currencies/networks, set a default fiat currency, callback URL, website/redirect URL, and a nonempty project webhook secret (`ipnSecret`). Create an active project API key. Merely adding an address does not enable every currency on that network.

Keep these values on your backend:

- `KINGPAY_API_ORIGIN`: `https://api-service.kingpay.online`, without a trailing slash. All Merchant endpoints below use this origin and short paths.
- `KINGPAY_API_KEY`: active API key belonging to the project.
- `KINGPAY_IPN_SECRET`: that project's webhook signing secret; it is not the API key.
- Your public HTTPS webhook URL and customer return URL.

Do not add `/api/payment` to this public origin: the production proxy already supplies that internal prefix. For local development without the proxy, prepend `/api/payment` to the documented paths on `http://localhost:3100` (including `/info`). Send `apikey: YOUR_PROJECT_API_KEY` as a request header. An `apikey` query parameter is also accepted and takes precedence, but prefer the header to avoid putting the key in URLs.

All currently documented payment creation and log routes use **GET**, including creation operations. Do not replace them with POST JSON requests. Encode query values with URLSearchParams or equivalent. Do not expose your API key in frontend JavaScript, browser redirects, analytics or logs. A disabled key/project or banned project owner cannot authenticate.

Examples use placeholders only. Configure the API origin independently from the URL of the hosted payment page; use the returned `payment_url`.

## 2. Choose an integration

| Need | Merchant endpoint | Result |
| --- | --- | --- |
| Fixed-price order | GET /checkout/request | Hosted payment link |
| Customer deposit | GET /deposit/request | Hosted deposit link |
| Your own payment UI | GET /{ticker}/create | Incoming address |
| Recurring invoices | GET /subscription/create | Subscription and payment links |
| Supported assets | GET /info?prices=1 | Public currency dictionary |

Recommended starting point: hosted checkout. Your backend creates the invoice, saves its ID against your order, returns only the payment URL to the browser, and processes verified webhooks.

The public payment page handles coin selection, addresses, QR codes and live progress. Do not make your business depend on a customer keeping that page open or returning to your website.

## 3. Hosted checkout

### Create

`GET /checkout/request`

| Query parameter | Required | Meaning |
| --- | --- | --- |
| value | Yes | Positive decimal amount in major fiat units: "10.50" means 10.50 USD when currency=USD |
| currency | If no project default | Active fiat code, e.g. USD; case insensitive |
| item_description | No | Order description shown on the payment page |
| external_id | Recommended | Your order/reference ID; not a server-side idempotency key |
| customer_email, customer_name | No | Customer details |
| notify_url | Recommended | Overrides the project's callback URL |
| redirect_url | If no project fallback | Absolute HTTP/HTTPS customer return URL with a hostname and without embedded credentials; falls back to project redirect URL, then website URL. Invalid URLs return 400. Relative URLs and other schemes are rejected. |
| expire_at | No | **Duration in seconds**, 3600–604800; default 3600 |
| confirmations | No | Positive safe integer; incoming confirmation threshold for UTXO/EVM/TRON |
| post | No | "1" selects form-encoded POST webhooks; otherwise GET |
| json | No | "1" selects POST JSON webhooks, taking precedence over post; recommended |

Configure a callback even though checkout creation itself does not reject a missing callback. Use `json=1` for typed JSON webhooks. This route does not accept a coin-selection parameter. Accepted currencies come from project settings.

```bash
curl --get "$KINGPAY_API_ORIGIN/checkout/request" \
  -H "apikey: $KINGPAY_API_KEY" \
  --data-urlencode "value=10.50" \
  --data-urlencode "currency=USD" \
  --data-urlencode "external_id=order-1042" \
  --data-urlencode "item_description=Order 1042" \
  --data-urlencode "confirmations=5" \
  --data-urlencode "expire_at=3600" \
  --data-urlencode "json=1" \
  --data-urlencode "notify_url=https://merchant.example/webhooks/kingpay" \
  --data-urlencode "redirect_url=https://merchant.example/orders/1042"
```

Illustrative response:

```json
{
  "status": "success",
  "payment_id": "EXAMPLE_INVOICE_UID",
  "success_token": "EXAMPLE_SUCCESS_TOKEN",
  "payment_url": "https://checkout.example/pay/EXAMPLE_INVOICE_UID"
}
```

Save `payment_id`, `success_token`, `payment_url`, your order ID, expected fiat amount and currency. Do not treat `success_token` or a browser redirect as proof of payment.

### Backend JavaScript example

```js
export async function createCheckout(order) {
  // order comes from your server-side order database, not client-supplied totals.
  const url = new URL('/checkout/request', process.env.KINGPAY_API_ORIGIN);
  url.search = new URLSearchParams({
    value: order.totalDecimalString,
    currency: order.currency,
    external_id: String(order.id),
    item_description: order.description,
    confirmations: '5',
    expire_at: '3600',
    json: '1',
    notify_url: 'https://merchant.example/webhooks/kingpay',
    redirect_url: 'https://merchant.example/orders/' + encodeURIComponent(order.id)
  }).toString();

  const response = await fetch(url, {
    headers: { apikey: process.env.KINGPAY_API_KEY },
    cache: 'no-store',
    signal: AbortSignal.timeout(15000)
  });
  const result = await response.json();
  if (!response.ok || result.status !== 'success') {
    throw new Error(result.error || 'KingPay request failed');
  }
  return result; // Persist before exposing result.payment_url to the customer.
}
```

A timeout does not prove creation failed. Do not automatically repeat creation: each call can create a new invoice even with the same external_id. Serialize creation per local order and retain successful results.

### Read status

`GET /checkout/logs?token=PAYMENT_ID`, with the API key header.

Response fields:

- `status`: API result, normally "success".
- `is_paid`, `is_pending`, `is_expired`, `is_partial`: boolean invoice summary.
- `payment_data[]`: `value` (expected crypto), `value_paid`, `value_outstanding`, `exchange_rate`, `coin`, `txid[]`.
- `notifications[]`: webhook delivery information when available.

An address may not exist until the customer selects a currency on the hosted page. `payment_data` can therefore be empty. Outstanding amounts can be negative on overpayment; do not interpret them as another amount to send.

Checkout notifications include pending, confirmed and completed delivery events, as well as legacy checkout.payment records. Inspect each notification's payload.payment_status for the incoming payment lifecycle; notification.pending describes delivery success, not blockchain confirmation state.

## 4. Payment lifecycle and accounting

There are three different concepts. Do not put them in one enum:

| Field/context | Values | Meaning |
| --- | --- | --- |
| API response status | success, error | Whether the API request succeeded |
| Webhook payment_status | pending, confirmed, completed | Lifecycle of one incoming payment |
| Invoice status / invoice_status | pending, partially_paid, paid, expired (and other stored terminal states if applicable) | Aggregate invoice state |

- **pending**: incoming payment detected; do not credit or fulfill yet.
- **confirmed**: required incoming confirmation policy is met; the incoming payment is accepted.
- **completed**: distribution has been sent successfully to the network. This does not mean an additional outgoing finality threshold has been reached.

For a checkout, fulfill the order once its invoice is fully paid (`is_paid === "1"` / `invoice_status === "paid"`) in a verified confirmed/completed notification or an authenticated status reconciliation. A confirmed partial transfer is not a fully paid invoice.

For deposits, credit each confirmed incoming transfer once. A completed event for that same transfer updates settlement details; it is not another deposit. If completed arrives before confirmed, apply the accepted payment once, then record distribution.

Use decimal arithmetic for financial values; do not add or compare monetary amounts using binary floating-point. Most payment amounts are decimal strings. Some subscription payload amounts and exchange rates are numbers: normalize deliberately.

### Amount fields and units

Amounts use ordinary currency units, not cents or blockchain base units. For example, `"10"` USD means 10 USD, and `"0.01"` BTC means 0.01 BTC. JSON types below describe the signed payload; GET/form transport encodes fields as text. Preserve the documented types when verifying signatures.

| Context / field | JSON type | Unit and meaning | Availability |
| --- | --- | --- | --- |
| Checkout webhook `value` | string or null | Requested invoice amount in `currency`; not an incoming or net amount | Every payment stage; null if no invoice amount |
| Checkout/deposit `paid_amount` | string | Incoming crypto in `paid_coin`, before service and network fee deductions | Every payment stage |
| Checkout/deposit `paid_amount_fiat` | string or null | Fiat equivalent of the incoming amount in `currency`, before fees | Every payment stage; see fallback below |
| Checkout/deposit `received_amount` | string | Crypto distributed to the merchant in `paid_coin`, after fee deductions | Only completed webhooks |
| Checkout/deposit `received_amount_fiat` | string or null | Fiat equivalent of the distributed amount in `currency`, after fees | Only completed webhooks; see fallback below |
| Subscription webhook `subscription_option_value` | number | Requested plan price in `payment_currency`, rounded to two decimal places | Renewal notifications; 0 if the plan amount is unavailable |
| Subscription webhook `payment_paid_amount` / `payment_paid_amount_fiat` | number / number or null | Incoming crypto in `payment_paid_coin` / fiat in `payment_currency`, before fees | Every renewal payment stage; fiat uses the same fallback below |
| Subscription webhook `payment_received_amount` / `payment_received_amount_fiat` | number / number or null | Distributed crypto / fiat equivalent, after fees | Only completed renewal webhooks; fiat uses the same fallback below |
| Subscription list/fetch `payment_list[].value` | string or null | Requested invoice amount in fiat identified by `coin` | For each invoice with a detected payment; null if no invoice amount |
| Subscription list/fetch `payment_list[].received_fiat` | string or null | Total accepted incoming fiat for that invoice, **before fees**; our service commission is not deducted | Sums confirmed/completed payments once; `"0"` if none qualify; null if any qualifying payment has no fiat valuation |
| Custom/Core `value_coin` | number or string | Incoming crypto in `coin`, before fees | Payment events; type depends on the source network payload |
| Custom/Core `value_forwarded_coin` | number or string | Distributed crypto in `coin`, after fees | Distribution details; absent before available |
| Custom/Core `fee_coin` / `network_fee` | number, string or null | Service fee / separate network-cost deduction, both in the payment coin or token | Distribution details; absent or null when unknown. Network deduction is not necessarily the final on-chain gas cost |

Fiat fallback in checkout/deposit/subscription webhooks: when the stored incoming fiat valuation is missing, `paid_amount_fiat` uses the requested invoice amount if available. When the stored net fiat valuation is missing, `received_amount_fiat` falls back to the incoming fiat valuation (or its invoice fallback). Both can be null if no value is available. Consequently, a fallback fiat value does not prove the exact transferred amount or fee; use crypto amounts for settlement reconciliation. Subscription webhook fields have the `payment_` prefix. The list/fetch `received_fiat` total does **not** use these fallbacks.

Example with known fiat valuations: a customer pays 10 USD and total deductions are 0.20 USD. The subscription history has `received_fiat: "10"`; the renewal webhook at completed has `payment_paid_amount_fiat: 10` and `payment_received_amount_fiat: 9.8`. A later completed event does not increase `received_fiat` again.

These stage rules describe new webhooks. Logs expose stored payment data and historical payloads: an unknown distribution amount may still be stored as zero or null before completed. Do not treat it as a completed settlement. There is no `value` field in deposit webhooks and no `payment_value` field in subscription webhooks.

### Confirmations by chain

- UTXO and EVM: pending progress can include updated `confirmations`; confirmed follows the configured threshold, completed follows distribution.
- Solana: confirmed/completed lifecycle without a numeric per-block progress stream. Do not fabricate a counter or wait for a requested number of Solana confirmations.
- TRON: the merchant's incoming threshold is separate from distribution readiness. A threshold of 5 can produce confirmed at 5 while distribution waits at least 23 confirmations for TRX or 20 for TRC20 and a successful solidified receipt.
- Do not assume every intermediate event arrives in order, or that each integer confirmation will produce exactly one delivery.

### Partial, late and excess payments

Keep the same invoice/reference for a top-up. Record each incoming transaction separately and only fulfill a fixed-price order once the aggregate invoice is paid. Once a payment is detected, do not offer a network/address switch for that payment.

The checkout expiration deadline is not proof that money cannot arrive. Late payments can still be credited, and a previously expired invoice can become paid. Never downgrade paid to expired merely because a timer elapsed. Decide your own fulfillment policy for late payments explicitly.

Overpayment is not an automatic refund instruction. Preserve the actual received values and handle any refund as a separately authorized operation.

## 5. Merchant webhooks

Callback URLs must use HTTP(S) and resolve only to public Internet IP addresses. Loopback, private, link-local, shared/reserved addresses and unsafe IPv6 ranges are blocked, including when returned by DNS. URL credentials and fragments are not allowed. Redirects are not followed. Destinations are checked again on every delivery, including queued retries and manual resends; changing DNS cannot bypass the connection check. Use a public HTTPS endpoint for integrations. Private development exceptions are deployment-controlled exact endpoints, not a merchant API option.

### Transport and acknowledgement

- Default: GET query parameters.
- `post=1`: POST `application/x-www-form-urlencoded`.
- Checkout, deposit, subscription and custom payment creation support `json=1`: POST `application/json`; JSON takes precedence over `post`, even with `post=0`. Without either flag, delivery remains GET.
- Subscription JSON selection also applies to renewal invoices and expiration notifications. Repeating subscription creation updates transport on a reused payable invoice. Already queued deliveries retain their saved transport.
- GET/form transport converts values to strings, JSON-encodes nested values, and omits null values. JSON mode preserves types/nulls.

After verifying and durably storing the event, return HTTP 2xx with either:

```text
*ok*
```

or:

```json
{"status":"ok"}
```

HTTP 200 with an empty body or {"success":true} is not sufficient. Redirects are not followed.

Delivery timeout is 30 seconds. Automatic retries run within a 48-hour delivery window: approximately 1 minute, 5 minutes, 15 minutes, 1 hour, then 3-hour intervals (worker scheduling can add delay). Acknowledge duplicates too, after checking authenticity. Use durable event storage and process slow work asynchronously.

### Checkout payload

Illustrative canonical JSON representation of a confirmed checkout event; POST form sends string values, not this JSON object:

```json
{
  "uuid": "EXAMPLE_EVENT_UUID",
  "payment_id": "EXAMPLE_INVOICE_UID",
  "payment_url": "https://checkout.example/pay/EXAMPLE_INVOICE_UID",
  "redirect_url": "https://merchant.example/orders/1042",
  "value": "10.5",
  "currency": "usd",
  "is_paid": "1",
  "paid_amount": "10.5",
  "paid_amount_fiat": "10.50",
  "paid_coin": "polygon_usdc",
  "exchange_rate": 1,
  "txid": "EXAMPLE_INCOMING_TRANSACTION_HASH",
  "address": "EXAMPLE_INCOMING_ADDRESS",
  "type": "payment",
  "status": "done",
  "customer_email": "null",
  "customer_name": "null",
  "external_id": "order-1042",
  "payment_status": "confirmed",
  "invoice_status": "paid",
  "confirmations": 5,
  "required_confirmations": 5
}
```

Important field rules:

- `value` in a checkout webhook is the requested invoice amount in ordinary fiat units, serialized as a decimal string: `"10.5"` means 10.50 USD when `currency` is `"usd"`. No multiplication by 100 is applied. Trailing zeros may be omitted.
- `paid_amount` and `paid_amount_fiat` describe this incoming payment, not an extra credit for every lifecycle notification.
- `received_amount` and `received_amount_fiat` are distribution/net values and are removed until completed.
- `paid_coin` uses a slug such as `polygon_usdc`, `trc20_usdt`, `sol_usdc`, or `ltc`; it is not the slash-form URL ticker.
- `status="done"` is a legacy aggregate invoice indicator. It does not replace payment_status.
- Unset customer_email/customer_name currently serialize as the literal string "null"; normalize only after signature verification.
- Event labels such as `checkout.payment.confirmed`, `checkout.deposit.completed`, `custom.payment.pending`, `subscription.renew.confirmed` identify stored delivery events. They are not guaranteed as a `type` field in the transmitted payload. Checkout's type is "payment".
- `uuid` identifies notifications. Retries preserve the saved payload. Custom flows can preserve a source UUID; combine it with lifecycle/progress for event deduplication rather than assuming it uniquely identifies every lifecycle stage.

### Idempotent processing

Within your own database transaction:

1. Verify signature and timestamp before trusting any field.
2. Resolve the invoice/order using your saved mapping and verify project context, external_id and currency.
3. Store the delivery idempotently (project + uuid + payment_status + confirmations, or a canonical payload hash).
4. Separately ensure a credit cannot be inserted twice for the same project + invoice + network/asset + incoming txid + incoming address.
5. Advance lifecycle monotonically: pending → confirmed → completed. Late pending events cannot undo confirmed/completed.
6. For checkout, fulfill once per invoice/order only when fully paid. For deposits, book each accepted incoming payment once.
7. Commit before acknowledging.

Do not use the timestamp, an outgoing transaction hash, or `completed` as a new incoming-credit identity. Do not credit both a webhook and a reconciliation independently.

## 6. Verify signatures

This section describes **KingPay Merchant-to-store** HMAC signatures. Direct **Core API** callbacks instead use `x-ca-signature`: Base64 RSA-SHA256 with PKCS#1 v1.5 padding. Get and pin the public key from `/pubkey/` on the trusted Core origin. Verify the exact full URL for GET, or raw body bytes for POST, before parsing. Do not sort/re-encode a Core query or reserialize a Core body; do not fetch a key from a location supplied by the callback. POST query parameters are not signed. Core RSA has no signed timestamp: keep processing idempotent for replay and delayed lifecycle events. Existing Merchant HMAC signatures and timestamp checks below remain unchanged.

Set a nonempty project ipnSecret. The sender only adds signature headers when the secret exists; your receiver should reject unsigned events, not silently disable verification.

Headers:

- `X-CryptAPI-Signature`: lowercase hexadecimal HMAC-SHA256.
- `X-CryptAPI-Signature-Timestamp`: Unix seconds generated for the delivery attempt.
- `X-CryptAPI-Signature-Alg`: HMAC-SHA256.

Signed message: `canonicalJSON + "|" + timestamp`.

Canonical JSON sorts object keys recursively, preserves array order, preserves primitive types and null, and omits undefined values. No whitespace is added. Do not sign the raw form body or query string.

### Node.js verification helper

This function accepts the **typed original payload** and a Fetch API `Headers` object (`request.headers`), not an unconverted form object. Use the parsed JSON body directly for `json=1` callbacks. Reject malformed JSON before calling it. For checkout form callbacks, reconstruct types as shown below first. A valid signature does not replace idempotency: a delivery can be replayed within the accepted ±300-second window.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function canonicalize(value) {
  if (Array.isArray(value)) return value.map(canonicalize).filter(item => item !== undefined);
  if (value !== null && typeof value === 'object') {
    return Object.fromEntries(
      Object.keys(value).sort()
        .filter(key => value[key] !== undefined)
        .map(key => [key, canonicalize(value[key])])
    );
  }
  return value;
}

export function verifyKingPay(payload, headers, secret, now = Date.now()) {
  const signature = headers.get('x-cryptapi-signature') || '';
  const timestamp = headers.get('x-cryptapi-signature-timestamp') || '';
  if (!secret || headers.get('x-cryptapi-signature-alg') !== 'HMAC-SHA256') return false;
  if (!/^[a-f0-9]{64}$/i.test(signature) || !/^\d+$/.test(timestamp)) return false;
  const seconds = Number(timestamp);
  if (!Number.isSafeInteger(seconds) || Math.abs(now / 1000 - seconds) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(JSON.stringify(canonicalize(payload)) + '|' + timestamp)
    .digest();
  return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
```

### Checkout form reconstruction

GET/form transport loses primitive types and omits nulls, while the signature includes them. Generic "sort request.body and sign it" will fail. This adapter is specifically for the checkout payment payload schema above. It is **not** a deposit, subscription or custom-event adapter.

Parse the form as a flat URLSearchParams (reject duplicate keys), then:

```js
export function checkoutPayloadFromForm(params) {
  const payload = {};
  for (const [key, value] of params) {
    if (Object.prototype.hasOwnProperty.call(payload, key)) throw new Error('Duplicate field');
    Object.defineProperty(payload, key, { value, enumerable: true, writable: true });
  }
  const nullable = ['redirect_url', 'value', 'currency',
    'paid_amount_fiat', 'exchange_rate', 'external_id'];
  if (payload.payment_status === 'completed') nullable.push('received_amount_fiat');
  for (const key of nullable) {
    if (!(key in payload)) payload[key] = null;
  }
  for (const key of ['exchange_rate', 'confirmations', 'required_confirmations']) {
    if (payload[key] != null) {
      if (payload[key].trim() === '') throw new Error('Empty numeric field');
      const number = Number(payload[key]);
      if (!Number.isFinite(number)) throw new Error('Invalid numeric field');
      payload[key] = number;
    }
  }
  return payload;
}

// POST form receiver: parse only the body, not unrelated callback URL parameters.
// const payload = checkoutPayloadFromForm(new URLSearchParams(rawBody));
// if (!verifyKingPay(payload, request.headers, projectSecret)) reject();
// Validate the verified payload schema and apply the database workflow above.
// Return {"status":"ok"} only after durable acceptance.
```

Keep decimal-string amounts (including `value`) and `is_paid` as strings. Do not turn every numeric-looking string into a number. Do not turn literal customer "null" strings into null before checking the signature.

Previously queued checkout webhooks retain their original payload on retry, including the legacy numeric `value` multiplied by 100. The adapter above targets newly generated payloads; do not rewrite old signed payloads before verification.

For GET delivery, use a callback URL without extra query parameters to avoid mixing unsigned static query values into the payload. If you need an order reference, use external_id and your saved payment_id mapping.

Deposit/subscription/form custom callbacks require their own typed reconstruction matching their payload schemas. Do not reuse the checkout adapter. If a signature cannot be verified, quarantine the notification and reconcile through authenticated API calls; never count an unverifiable notification as paid.

## 7. Deposits

`GET /deposit/request`

Uses the same API key header. Parameters: optional `value`, `currency`, `item_description`, `external_id`, `customer_email`, `customer_name`, `notify_url`, `redirect_url`, `post`, `json`, `expire_at`.

Differences from checkout:

- value is optional; if supplied, it must be positive and a fiat currency must resolve.
- Fiat currency is optional without a value.
- notify_url or the project's callback is required.
- **expire_at is a duration in seconds**, 3600–604800; default 3600, identical to checkout and subscriptions. For example, expire_at=7200 expires two hours after creation. Do not send a Unix timestamp. Existing invoices retain their stored expiration dates.
- `json=1` selects POST JSON callbacks; no confirmations override is read by this route.
- Response: `{status:"success", deposit_id, success_token, payment_url}`.

`GET /deposit/logs?token=DEPOSIT_ID` returns:

- `deposits[]`: txid, paid_amount, paid_amount_fiat, received_amount, received_amount_fiat, coin, payment_status, confirmations, required_confirmations, timestamp.
- `total_deposited`, `currency`, `notifications[]`, status.
- total_deposited sums paid_amount_fiat only for confirmed/completed payments. Pending entries remain visible in deposits[] but do not increase the total. If only pending payments have fiat values, the total is "0.00"; if no payments have fiat values, it is null. Credit individual accepted payments idempotently rather than repeatedly crediting the cumulative total.

Deposit webhooks use `type:"deposit"`, payment_id, payment_url, currency, paid_amount, paid_amount_fiat, paid_coin, exchange_rate, txid, address, customer fields, external_id, status and shared lifecycle fields. Completed adds received_amount/received_amount_fiat. They do not include checkout's value/is_paid/redirect_url fields.

## 8. Custom payment addresses

`GET /{ticker}/create`

Examples: `/ltc/create`, `/trc20/usdt/create`, `/sol/usdc/create`.

| Parameter | Meaning |
| --- | --- |
| callback | Required merchant callback URL |
| address | Optional payout address; otherwise use configured project payout addresses |
| address with shares | Fractions summing to 1, e.g. 0.7@ADDRESS_A\|0.3@ADDRESS_B; URL-encode the entire value |
| post | "1" for form POST |
| json | "1" for JSON POST callbacks; preferable for unambiguous signature verification |
| min_split_amount | Optional decimal forwarded to address creation |
| pending | Legacy flag passed to Core; not a guaranteed switch for lifecycle progress notifications |
| confirmations | Positive safe integer incoming threshold; passed to Core when supplied |
| priority | default, fast or economic; passed to Core |
| multi_token, convert | "1" enables the corresponding Core option; support depends on the chain |

Response: status, address_in, address_out, callback_url, priority, minimum_transaction_coin.

The Merchant handler forwards pending, confirmations, priority, multi_token, convert and min_split_amount to Core creation. Chain-specific confirmation rules still apply, including Solana's stage-based confirmation and TRON's separate distribution readiness. Merchant post/json flags control Merchant-to-store delivery; the internal Core-to-Merchant callback remains GET.

Each call creates a local invoice. Use a distinct callback URL per logical custom integration address because logs find an invoice by project + currency + callback and do not reliably distinguish multiple creations sharing that callback.

`GET /{ticker}/logs?callback=URL` returns address/configuration data and `callbacks[]` with incoming/outgoing IDs, lifecycle, confirmation counts, amounts, fee breakdowns, forwards and delivery attempts. Outgoing IDs/fees can be absent until distribution.

Custom webhook payload uses uuid, address_in, address_out, txid_in, coin, price, value_coin and lifecycle fields. Completed can add txid_out, value_forwarded_coin, fee_coin, network_fee and conversion fields. Do not assume the hosted checkout schema or that all custom callback query parameters are copied into the payload.

## 9. Subscriptions

Subscriptions create renewal invoices that customers pay from their wallets. They are not permission to automatically debit a wallet.

A fully paid invoice adds its subscription's configured interval exactly once. Early renewal extends the existing paid-through date; if it has expired or is unset, the period starts at the invoice's settlement time. Partial payments do not extend the period until the invoice is fully paid. Later `completed` notifications and additional payments to that same paid invoice do not add another period. Separate paid renewal invoices each add their period, including concurrent payments.

`GET /subscription/create`

Required:

- value: positive major-unit fiat amount.
- interval: day, week, month, three_month, year.
- user_id (or customer_id): your customer identifier.
- user_email (or customer_email): customer email.
- external_id: subscription reference; user_id is accepted as its fallback.
- currency or active project fiat default.
- notify_url or project callback; redirect_url or project redirect/website fallback.

Optional: option (plan label), item_description, post=1 (form callbacks), json=1 (JSON callbacks, takes precedence), expire_at. Here **expire_at is a duration**, 3600–604800 seconds, default 3600. No confirmations override is read.

Response contains status, token (subscription UID), subscription_url, payment_url, success_token. A reused-invoice response additionally contains reused_invoice=true and numeric subscription_id.

Existing uncancelled subscriptions are looked up by project + external_id. An active pending/partially_paid invoice is reused only when the requested amount, fiat currency, interval and plan option match its saved terms. Changing those terms returns HTTP 409 without updating the subscription. A missing option retains the current plan label. After the previous invoice is paid or expires, a new invoice may use new terms. Issued invoices keep their original amount, currency and interval: even a late payment renews that original interval. Creation and public renewal are serialized within each project. Delivery flags can still be updated on reuse; already queued deliveries remain unchanged.

Renewal webhooks report the paid invoice's amount and plan option. Their duration is the nominal saved interval (day: 86400; week: 604800; month: 2592000; three_month: 7776000; year: 31536000); actual billing dates use calendar intervals. Legacy invoices use their own amount/currency columns and metadata.interval. If the saved interval is absent or invalid, reuse returns 409 and callback processing fails for manual review instead of applying the current plan. Do not blindly retry a conflicting create.

`GET /subscription/list?p=1`: 50 subscriptions per page; returns status, subscriptions, num_pages.
`GET /subscription/fetch?token=SUBSCRIPTION_TOKEN`: subscription details and invoice information.

Create, fetch and list use the same subscription UID (subscribeUid). Pass create's token to fetch; subscription.token in fetch and subscriptions[].token in list return that same identifier. Queries are scoped to the API key's project. payment_list[].token identifies an individual invoice, not the subscription.

Fetch and list share the same response mapping. `url` is the permanent `/subscription/{subscribeUid}` page (null only if the public site URL is unconfigured). `end_date_ts` is the current period end from `nextBillingAt`, or null if unset; it is not the cancellation date. `payment_list[].is_pending` indicates an actual incoming payment awaiting blockchain confirmations, not simply an unpaid invoice. It can be true alongside partial payment while a top-up is confirming.

`payment_list` includes only invoices with at least one detected payment, newest invoice first, including pending, partial and paid invoices. `value` remains the requested invoice amount in fiat; `coin` identifies that fiat currency. `duration_ts` uses that invoice's saved interval and is null if unknown; changing the current plan does not change historical durations. `received_fiat` sums incoming `paidAmountFiat` for confirmed/completed payments before fees, using decimal strings. Pending, invalid and refunded payments do not contribute. It is `"0"` if none qualify, or `null` if any qualifying payment has an unknown fiat value. A transition from confirmed to completed updates the same payment and does not add its amount twice. These are KingPay's explicit rules for cases not fully specified by BlockBee.

Renewal notifications use action=renew, subscription_id (public UID), subscription_url, start/end timestamps, plan fields, user fields, and payment-prefixed fields: payment_url, payment_redirect_url, payment_success_token, payment_currency, payment_is_paid, payment_paid_amount, payment_paid_amount_fiat, payment_paid_coin, payment_exchange_rate, payment_txid, payment_address, payment_type. Completed adds payment_received_amount/payment_received_amount_fiat.

`payment_type` is `subscription`; there is no `payment_value` field. `subscription_option_value` is the plan's fiat amount. Subscription amount fields and `payment_is_paid` are JSON numbers. `subscription_id` is the public `subscribeUid` (creation response `token`), not the numeric database ID or external reference. Expiration is a separate `action=expired` schema: it uses `subscription_external_id` and has no payment fields.

The shared dispatcher sets payment_status to pending/confirmed/completed and adds invoice_status, confirmations, required_confirmations. Use payment_is_paid + invoice_status for fulfillment, not the old done meaning of payment_status.

Expiration events use action=expired with subscription identifiers, dates and plan/customer fields; they are subscription events, not incoming-payment lifecycle updates. Handle them separately.

## 10. Currencies, networks and fees

### Customer fee surcharges on hosted payments

The Merchant service selects a project commission from the configured base rate and the highest reached turnover tier. Turnover is cumulative gross USD from completed payments with usable historical USD valuations, not a rolling monthly total; pending/confirmed payments and payments without usable valuation are excluded. If a personal rate exists, the smaller of that rate and the project tier rate applies. A network exemption threshold takes precedence over the personal threshold, which otherwise takes precedence over the global threshold. TRON always forces this threshold to zero: there is no small-payment exemption there. Other networks are exempt strictly below their threshold; equality is not exempt. Network costs remain separate even when service commission is zero.

Merchant address issuance passes the selected commission policy to Core and saves it for that address. Merchant callers cannot choose their commission using query parameters. At distribution, Core applies the saved percentage and exemption threshold to the full current payout before expenses, potentially including multiple incoming transfers. The hosted quote's exemption check instead uses the base invoice USD amount. Therefore a quote's service surcharge and the actual distribution deduction need not be identical; use completed settlement data rather than recomputing deductions from catalog values.

Project settings can add service and/or network costs to new checkout and subscription renewal quotes. They do not set a target for open-amount deposits or custom payment addresses. The requested invoice/plan fiat `value` stays unchanged; incoming amounts and `received_fiat` include everything the customer actually paid before deductions.

For a selected currency, the website calculates `(base crypto amount + network surcharge) / (1 - service fee percent / 100)`. Disabled components are zero. The network surcharge uses the synchronized network fee estimate, multiplied by the number of merchant payout addresses, converted from the native network coin to the payment coin/token, plus a **10% reserve**. Amounts round upward to six decimal places or the asset's smaller configured precision. Missing required price/fee data prevents a new quote instead of silently treating the cost as zero.

At address issuance, the total is saved in `expectedAmount` with a quote snapshot in existing invoice metadata. Currency selection previews use the same calculation. Reusing an address returns its saved total even if prices, fees or project switches change. Existing addresses retain their original totals.

For newly quoted invoices, accepted incoming payments must cover the fixed crypto quote, including enabled surcharges. Paying only the original order value is partial payment. Once the fixed total is paid, higher actual network costs are absorbed by the service: no additional payment is requested and completed does not reopen the invoice. Payment QR codes, wallet requests and the remaining amount use that same fixed total. Fiat valuations in webhooks remain informational and do not override this payment threshold. This changes the hosted payment calculation, not Core API distribution.

`GET /info` is public; `prices=1` requests fiat prices. UTXO entries (btc, ltc, bch, doge) appear at the top level. Other networks contain assets keyed by lowercase symbol, including the native coin: erc20.eth, bep20.bnb, trc20.trx, polygon.matic, sol.sol. Each asset appears once, with its canonical network/symbol ticker, even when no other assets on that network are enabled. Entries expose ticker, coin, logo, contract, minimum_transaction, minimum_transaction_coin, minimum_fee, minimum_fee_coin, fee_percent, network_fee_estimation, prices and prices_updated as applicable.

Short native routes remain aliases: eth → erc20/eth, bnb → bep20/bnb, trx → trc20/trx, matic → polygon/matic, solana → sol/sol. Native SOL accepts only solana and sol/sol; sol and sol/solana are not public routes. Aliases are accepted on input, not duplicated in /info or by currency synchronization. An explicit cross-network asset such as bep20/eth remains distinct from erc20/eth. Existing Merchant webhook paid_coin slugs are unchanged.

The public catalog is not a project commission quote. Its `fee_percent` comes from the currency catalog; `fee_tiers` currently returns the compatibility placeholder `[{"minimum":"0.00","fee":"1.000"}]`, not configured project volume tiers or personal terms. Core's separate info endpoint also currently reports a fixed `fee_percent` of `"1.000"`. Do not use either as the effective Merchant commission. `network_fee_estimation` is in native network coin units even for tokens; completed custom/Core `network_fee` is in payment coin/token units. Custom completed payloads can expose `fee_coin` and `network_fee` separately; hosted checkout/deposit/subscription payloads expose gross and net amounts rather than the same fee breakdown.

Use returned tickers and project configuration rather than constructing a supported-asset list from symbols alone. USDT on TRON and USDT on Ethereum are different payment assets. URL tickers use slash notation; merchant paid_coin slugs use underscores, or a single code when network=code. Display friendly chain names separately from API identifiers.

Fees/minimums are dynamic. Do not hard-code historical TRON/NETTS prices, a fixed minimum invoice value, token decimals, or a fixed gas reserve into merchant code. An invoice can be created even when its small amount makes a particular currency unavailable on the checkout.

Paid/incoming amounts and net/distributed amounts have different meanings. Do not subtract network costs from the incoming credit a second time. Net settlement and fee details can arrive later at completed. A network's native gas currency is not necessarily the paid token's unit; do not add values of unlike currencies.

The anonymous Core API has separate endpoints, authentication/callback rules and address-forwarding semantics. Consult /documentation/anonymous/create-address and its callback documentation for that interface. Do not send a Merchant project key, assume the Merchant HMAC format, or combine its URL base with Merchant paths without explicitly choosing that API.

Both APIs default to GET, use form POST for `post=1`, and use JSON POST for `json=1` with priority over `post`. Their payload contracts differ: Core `convert=1` produces JSON-encoded strings in `value_coin_convert` and `value_forwarded_coin_convert`, even inside a JSON body. Parse those fields separately; missing/invalid/nonpositive source values become the string `"{}"`. Without conversion, the original scalar field can remain or be absent. Do not apply the Merchant payload typing rules to these Core fields.

## 11. Errors, retries and reconciliation

Merchant error shape:

```json
{"status":"error","error":"Invalid API key","statusCode":401}
```

Typical HTTP codes: 400 for invalid/missing parameters or unsupported fiat/ticker; 401 for missing/invalid key; 404 for unknown payment/subscription; 500 for internal errors. Upstream failures may surface as gateway or internal failures; inspect both HTTP code and body.

Recommended handling:

- Never log API keys, webhook secrets or full sensitive callback URLs.
- Do not cache/replay GET creation requests through a generic read-request cache.
- Retry read-only logs requests with bounded exponential backoff.
- On ambiguous creation failure, reconcile your saved local order state; do not create endless replacement invoices.
- Webhook duplicates and out-of-order delivery are normal cases to handle.
- A redirect is a navigation event, not a payment event.
- Reconcile pending local orders periodically and when your own order page resumes; avoid tight polling.
- Use authenticated logs for recovery and checkout notifications for lifecycle delivery history.
- Do not issue automatic refunds or resend a blockchain payment because a browser or webhook request timed out.

## 12. Implementation checklist

### Build

- Server-side create operation using project API key.
- Durable order-to-payment_id mapping and duplicate-create protection.
- Callback endpoint supporting the chosen actual transport.
- Nonempty project signing secret; signature and freshness validation.
- Schema-specific restoration of GET/form types/nulls before signature verification.
- Atomic event persistence, credit deduplication, and once-only order fulfillment.
- Decimal-safe accounting and explicit unit conversion.
- Pending/partial/paid/expired UI driven by backend status.
- Separate distribution display for completed.
- Scheduled reconciliation and webhook delivery diagnostics.

### Test without unnecessary blockchain spending

- Use local fixtures matching signed real schemas for most receiver tests.
- Valid signature, changed amount, wrong secret, stale timestamp, missing header.
- Null fields omitted in form transport, numeric versus string values, duplicate query/form keys.
- Same event twice; same incoming payment at confirmed then completed; completed before confirmed.
- Partial payment then top-up; overpayment; payment after expiration.
- Webhook storage failure (must not acknowledge before durable storage).
- API timeout during creation; no accidental duplicate invoice.
- One controlled live payment per relevant chain/asset flow after fixture tests pass.
- Never fabricate production paid state as a test shortcut.

### Instructions for an AI implementing a merchant integration

Read this whole guide first. Ask the merchant for stack, desired integration mode, order schema, deployment origins, callback endpoint, and fulfillment policy when missing. Use secret placeholders in generated code. Implement hosted checkout with `json=1` callbacks first unless another mode is requested. Do not invent POST creation routes, a universal form signature adapter, or unsupported subscription lookup behavior. Clearly identify application-specific persistence adapters and do not present pseudocode as a complete working accounting system.

## 13. Current limitations and maintenance

This is a versioned snapshot of the current code, not a promise of future behavior. Known mismatches deliberately documented here:

GET/form HMAC verification needs schema-specific typed reconstruction. Prefer `json=1` to preserve types and nulls.

Documentation work does not change those behaviors. Resolve changes separately and update this guide together with the handlers.

Maintainer source map: server/api/payment; server/api/info; server/utils/payments; server/utils/webhooks/{merchantDispatcher,canonicalPayload,worker,queue}; server/utils/webhooks/payloadBuilders; server/api/ipn/core.get.ts. Keep examples synchronized with these files.

Human-readable reference: /documentation, /documentation/kingpay/checkout-request, /documentation/kingpay/webhooks, /documentation/kingpay/webhook-verify-signatures.
