Merchant API Integration Guide
1. Overview
KidaPay is a crypto payment gateway. Your backend creates a transaction, KidaPay hosts the checkout, and your backend receives a signed webhook when the payment state changes. You never hold private keys, touch wallets, or manage on-chain confirmations.
Server-to-server only
Every endpoint authenticates with an x-api-key header. Keys must be used from your backend, never from frontend JavaScript or mobile apps.
Hosted checkout
You create a transaction and get back a payment_link. Redirect the customer there — KidaPay handles crypto selection, addresses, and confirmation.
Signed webhooks
Every payment state change is delivered as an HMAC-SHA256 signed webhook. Verify the signature before fulfilling.
curl --request POST \
--url https://api-dev-sandbox.kidapay.com/v1/transactions \
--header "Content-Type: application/json" \
--header "x-api-key: test_your_api_key" \
--data '{
"merchant_transaction_id": "ORDER-10001",
"amount": 25000,
"currency": "NGN",
"customer_email": "customer@example.com",
"callback_url": "https://your-site.com/payment/complete",
"webhook_url": "https://your-site.com/webhooks/kidapay"
}'Merchant API requests must be made from a secure backend server. API keys must never be placed in frontend JavaScript, mobile applications, public repositories, screenshots, analytics, or client-side storage.
2. Quickstart
From zero to a fulfilled order in five minutes, using sandbox keys.
1. Store your API key
Set the key from your dashboard as an environment variable on your backend. Never commit it.
KIDAPAY_API_KEY=test_your_api_key
KIDAPAY_BASE_URL=https://api-dev-sandbox.kidapay.com2. Create a transaction
curl --request POST \
--url "$KIDAPAY_BASE_URL/v1/transactions" \
--header "Content-Type: application/json" \
--header "x-api-key: $KIDAPAY_API_KEY" \
--data '{
"merchant_transaction_id": "ORDER-10001",
"amount": 25000,
"currency": "NGN",
"customer_email": "customer@example.com",
"callback_url": "https://your-site.com/payment/complete",
"webhook_url": "https://your-site.com/webhooks/kidapay"
}'3. Redirect the customer
Take payment_link from the response and send the customer's browser there. Do not proxy it, embed it in an iframe, or modify the query string.
4. Receive the webhook
KidaPay POSTs to your webhook_url when payment state changes. Verify the signature, then update your order.
import express from "express";
const app = express();
app.post(
"/webhooks/kidapay",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyKidapayWebhook({
rawBody: req.body.toString("utf8"),
timestamp: req.header("x-kidapay-timestamp"),
receivedSignature: req.header("x-kidapay-signature"),
apiKey: process.env.KIDAPAY_API_KEY,
});
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// Fulfil asynchronously; ack fast.
queue.add("fulfil", event);
return res.status(200).end();
},
);Never fulfil an order from the browser redirect alone. Fulfil only from a verified webhook or a server-to-server GET /v1/transactions/{id}.
5. Handle duplicate events
Webhooks are delivered at-least-once. Use transaction_id + status + payment_status as your idempotency key.
6. Go live
- Complete the sandbox checklist in §15 (see below).
- Request live approval from your account manager.
- Swap your
test_key for aprod_key and point at the production base URL.
3. Authentication
Merchant integration endpoints use an API key in the x-api-key header.
Content-Type: application/json
Accept: application/json
x-api-key: test_your_api_key
x-request-id: optional-unique-request-referenceKey types
| Field | Type | Required | Description |
|---|---|---|---|
| test_* | Sandbox | Yes | Used with the sandbox base URL. A sandbox key against production, or vice versa, returns 401. |
| prod_* | Live | Yes | Used with the production base URL. Requires merchant approval for live operations. |
Key lifecycle
- Rotate keys if they may have been exposed. Old keys stop working immediately after rotation.
- Scope keys per environment. A sandbox key never works against production.
- Store keys in a secrets manager (AWS Secrets Manager, Vault, Doppler, etc.), never in plaintext config files.
- Audit any location a key has been logged. If it appeared in a log or screenshot, rotate it.
Authentication errors
Missing API key:
{
"statusCode": 401,
"message": "API key is missing.",
"timestamp": "2026-10-09T10:00:00.000Z",
"path": "/v1/transactions"
}Invalid, inactive, or environment-incompatible key:
{
"statusCode": 401,
"message": "Invalid API key, please also verify the environment.",
"timestamp": "2026-10-09T10:00:00.000Z",
"path": "/v1/transactions"
}Merchant not authorized for live mode:
{
"statusCode": 403,
"message": "You are not authorized to access live mode",
"timestamp": "2026-10-09T10:00:00.000Z",
"path": "/v1/transactions"
}API keys must only be used by the merchant's backend.
4. Payment flow
Every payment follows the same sequence. Your backend creates a transaction, KidaPay hosts the checkout, and your backend receives a signed webhook when the state changes.
- A customer selects “Pay with Crypto” on the merchant's website.
- The merchant frontend calls the merchant's own backend.
- The merchant backend creates a KidaPay transaction using its API key.
- KidaPay returns a hosted
payment_link. - The merchant redirects the customer to the payment link.
- The customer selects a cryptocurrency and completes, cancels, or abandons the payment.
- KidaPay sends a signed webhook when the payment is successful, partially paid, cancelled, or expired.
- The merchant verifies the webhook signature and updates its local order.
- The customer is redirected to the configured
callback_url, where applicable.
Steps 1–3 happen entirely on your infrastructure. Everything from step 4 onward is driven by the customer inside KidaPay’s hosted checkout; your backend only learns about it via the webhook in step 7.
Three URLs, three purposes
| Field | Type | Required | Description |
|---|---|---|---|
| payment_link | URL | Yes | Returned by the create endpoint. The customer's browser goes here to pay. |
| callback_url | URL | No | Where the customer's browser goes after payment completes or is cancelled. Not a fulfilment signal. |
| webhook_url | URL | No | Your server endpoint. KidaPay POSTs signed payment notifications here. This is the fulfilment signal. |
Never fulfil an order from the browser redirect alone. Fulfil only from a verified webhook or a server-to-server GET /v1/transactions/{id}.
If a webhook never arrives
Webhooks are best-effort. Run a reconciliation job that polls GET /v1/transactions?page=1&limit=50 on a schedule (1–5 minutes) and reconciles any local order in a non-terminal state against the API response, matching on merchant_transaction_id.
5. Transaction lifecycle
Every transaction is in exactly one state at a time. Persist the latest state on your order and treat the transitions below as the only legal edges.
Transitions
| Field | Type | Required | Description |
|---|---|---|---|
| NEW → SUCCESSFUL | happy path | No | Full amount received. |
| NEW → PARTIALLY_PAID | underpayment | No | Received less than required. May still become SUCCESSFUL if topped up. |
| NEW → CANCELLED | customer action | No | Customer or checkout flow cancelled the payment. |
| NEW → NOT_PAID | expiry | No | Checkout expired without any payment. |
| NEW → FAILED | processing error | No | Treat as terminal. |
| SUCCESSFUL → REFUNDED | merchant action | No | Only reachable from SUCCESSFUL. |
Do not mark a PARTIALLY_PAID order as failed. Wait for the terminal state (SUCCESSFUL, NOT_PAID, or CANCELLED) before deciding what to do with the order.
Checkout payment_status (webhooks only)
Webhook payloads include both status (transaction-level) and payment_status (checkout-level). The latter is finer-grained and only appears in webhook bodies.
NEW, WAITING_FOR_PAYMENT, PENDING, PAID, PARTIALLY_PAID, FAILED, FAILED_PAYMENT, EXPIRED_PAYMENT, CANCELLED, COMPLETED
6. Environments
Sandbox and live are fully isolated — different base URLs, different keys, different data. Nothing you do in one affects the other.
Base URL: https://api-dev-sandbox.kidapay.com
Key prefix: test_
Swagger: https://api-dev-sandbox.kidapay.com/api/swagger
Purpose: development, CI, integration tests.
Base URL: https://api-sandbox.kidapay.com
Key prefix: prod_
Requires merchant approval for live operations.
Purpose: real customer funds.
Sandbox and live transactions, balances, refunds, withdrawals, and payment checkouts are stored separately. Data created in sandbox does not appear in production and vice versa.
7. Create a transaction
Creates a KidaPay transaction and returns a hosted checkout link.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| merchant_transaction_id | string | Yes | Unique merchant-generated transaction reference. Must be unique per environment. |
| amount | number | Yes | Fiat amount to be paid. |
| currency | string | Yes | Fiat currency: NGN, KES, or USD. |
| customer_email | string | No | Customer email address. |
| pay_currency | string | No | Exact cryptocurrency identifier, such as USDT_BSC. |
| title | string | No | Transaction title displayed at checkout. |
| description | string | No | Transaction description. |
| callback_url | URL | No | Browser destination after payment completion or cancellation. |
| webhook_url | URL | No | Merchant server endpoint that receives payment notifications. |
If callback_url or webhook_url is omitted, KidaPay uses the corresponding URL configured against the API key. Transaction-level URLs take priority over API-key-level URLs.
Request
{
"merchant_transaction_id": "ORDER-10001",
"customer_email": "customer@example.com",
"amount": 25000,
"currency": "NGN",
"title": "Payment for Order 10001",
"description": "Two items from Example Store",
"callback_url": "https://merchant.example.com/payment/complete",
"webhook_url": "https://merchant.example.com/webhooks/kidapay"
}Response
HTTP 201 Created
{
"transaction": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchant_transaction_id": "ORDER-10001",
"amount": 25000,
"pay_amount": null,
"currency": "NGN",
"pay_currency": null,
"title": "Payment for Order 10001",
"description": "Two items from Example Store",
"callback_url": "https://merchant.example.com/payment/complete",
"webhook_url": "https://merchant.example.com/webhooks/kidapay",
"is_paid": false,
"status": "NEW",
"customer_email": "customer@example.com",
"created_at": "2026-10-09T10:00:00.000Z",
"updated_at": "2026-10-09T10:00:00.000Z"
},
"payment_link": "https://pay.example.com/pay?transaction_id=550e8400-e29b-41d4-a716-446655440000&env=sandbox",
"business_name": "Example Store",
"business_logo": "https://merchant.example.com/logo.png"
}Duplicate reference
{
"statusCode": 400,
"message": "Transaction with merchant_transaction_id ORDER-10001 already exists",
"timestamp": "2026-10-09T10:00:00.000Z",
"path": "/v1/transactions"
}If you retry after a timeout, reuse the same merchant_transaction_id. A duplicate returns 400 — look up the existing transaction and reuse its payment_link rather than creating a second one.
8. Get a transaction
Retrieves a transaction belonging to the authenticated merchant.
curl \
--url https://api-dev-sandbox.kidapay.com/v1/transactions/550e8400-e29b-41d4-a716-446655440000 \
--header "x-api-key: test_your_api_key"Response
{
"transaction": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchant_transaction_id": "ORDER-10001",
"amount": 25000,
"pay_amount": 15.42,
"currency": "NGN",
"pay_currency": "USDT_BSC",
"title": "Payment for Order 10001",
"description": "Two items from Example Store",
"is_paid": false,
"status": "NEW",
"created_at": "2026-10-09T10:00:00.000Z",
"updated_at": "2026-10-09T10:00:00.000Z"
},
"payment_link": "https://pay.example.com/pay?transaction_id=550e8400-e29b-41d4-a716-446655440000&env=sandbox",
"business_name": "Example Store",
"business_logo": "https://merchant.example.com/logo.png"
}Returns merchant-owned transactions regardless of payment status, including: NEW, PARTIALLY_PAID, SUCCESSFUL, NOT_PAID, CANCELLED, FAILED, REFUNDED. Transactions belonging to another merchant return 404 Transaction not found.
9. List transactions
Returns a paginated list of transactions belonging to the authenticated merchant.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| page | number | No | Page number. Default: 1 |
| limit | number | No | Number of transactions per page. Default: 10 |
curl \
--url "https://api-dev-sandbox.kidapay.com/v1/transactions?page=1&limit=10" \
--header "x-api-key: test_your_api_key"Response
{
"data": [
{
"transaction": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchant_transaction_id": "ORDER-10001",
"amount": 25000,
"pay_amount": 15.42,
"currency": "NGN",
"pay_currency": "USDT_BSC",
"title": "Payment for Order 10001",
"description": "Two items from Example Store",
"is_paid": true,
"status": "SUCCESSFUL",
"created_at": "2026-10-09T10:00:00.000Z",
"updated_at": "2026-10-09T10:07:00.000Z"
},
"payment_link": "https://pay.example.com/pay?transaction_id=550e8400-e29b-41d4-a716-446655440000&env=sandbox",
"business_name": "Example Store",
"business_logo": "https://merchant.example.com/logo.png"
}
],
"total": 1,
"page": 1,
"limit": 10,
"hasNextPage": false,
"hasPreviousPage": false
}10. Refunds
Only a SUCCESSFUL transaction that belongs to your account can be refunded. Partial refunds are allowed. The destination address is required for both.
10.1 Preview the refund
Returns the exact refund amount, fee, total deduction, and whether your balance covers it. Call this before creating the refund.
{
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"refund_type": "FULL",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
}{
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"refund_type": "PARTIAL",
"pay_amount": 5.25,
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
}Calculate response
{
"statusCode": 200,
"description": "Refund calculation completed",
"data": {
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"refund_type": "FULL",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"pay_currency": "USDT_BSC",
"refundAmount": 15.42,
"refundAmountFiat": "25000.00",
"feeAmount": 0.1,
"feeAmountFiat": "162.13",
"totalDeductionAmount": 15.52,
"totalDeductionAmountFiat": "25162.13",
"minRefundAmount": 1,
"availableBalance": 100,
"fiatCurrency": "NGN",
"isSufficient": true
}
}10.2 Create the refund
Same shape as calculate, plus a free-text reason for your records.
{
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"refund_type": "FULL",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"reason": "Customer requested cancellation"
}{
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"refund_type": "PARTIAL",
"pay_amount": "5.25",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"reason": "One item was unavailable"
}A refund returns immediately with a queued state, but the on-chain transfer completes asynchronously. Do not tell the customer funds are returned until you receive the refund webhook or confirm via GET /v1/transactions/{id}.
11. Withdrawals
Withdrawals transfer cryptocurrency from the merchant's KidaPay balance to an external blockchain address.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
| address | string | Yes | Destination blockchain address. |
| pay_currency | string | Yes | Exact cryptocurrency identifier, such as USDT_BSC. |
| pay_amount | string | Yes | Requested amount represented as a string. |
| pay_amount_currency | string | Yes | The same cryptocurrency or NGN, KES, or USD. |
Request
{
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"pay_currency": "USDT_BSC",
"pay_amount": "25.00",
"pay_amount_currency": "USDT_BSC"
}Response
HTTP 201 Created
{
"statusCode": 200,
"description": "Withdrawal created successfully",
"data": {
"id": "fb55e308-a95c-4a55-8c54-ab01ad76d95d",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"pay_amount": "25",
"pay_currency": "USDT_BSC",
"status": "SUCCESSFUL",
"created_at": "2026-10-09T10:00:00.000Z",
"updated_at": "2026-10-09T10:00:00.000Z"
}
}Possible withdrawal statuses: PENDING, SUCCESSFUL, FAILED. The backend validates the currency, balance, fee, minimum withdrawal amount, destination, and master-wallet liquidity.
12. Webhooks — overview
KidaPay sends an HTTP POST to your webhook_url when a transaction reaches a terminal state. Delivery is at-least-once; your receiver must be idempotent and verify the signature on every request.
Webhooks fire for:
- Successful payment.
- Partial payment.
- Cancelled payment.
- Expired payment.
If a transaction does not have a webhook_url, KidaPay uses the URL configured against your API key.
Fulfilment must be based on a verified webhook or a server-to-server transaction lookup — never on the browser landing at callback_url.
Payment cancellation
Cancellation is performed by the hosted KidaPay checkout frontend — it is not a merchant-callable endpoint. When a customer cancels:
- The order becomes
CANCELLED. - The checkout becomes
CANCELLED. - The checkout address is released.
- You receive a signed CANCELLED webhook (see Payloads).
Cancellation is idempotent. A paid, partially paid, expired, or otherwise completed checkout cannot be cancelled.
13. Webhooks — signature verification
Every webhook carries two headers. Verify both before doing anything with the payload.
Content-Type: application/json
x-kidapay-signature: sha256=<hexadecimal-signature>
x-kidapay-timestamp: <unix-timestamp-in-seconds>KidaPay signs timestamp + "." + rawBody using HMAC-SHA256. The signing secret is the API key that created the transaction.
Node.js example
import crypto from "crypto";
function verifyKidapayWebhook({
rawBody,
timestamp,
receivedSignature,
apiKey,
}) {
if (!rawBody || !timestamp || !receivedSignature) {
return false;
}
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature =
"sha256=" +
crypto
.createHmac("sha256", apiKey)
.update(signedPayload, "utf8")
.digest("hex");
const expectedBuffer = Buffer.from(expectedSignature);
const receivedBuffer = Buffer.from(receivedSignature);
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}Parsing and re-serialising the JSON may change the payload and cause verification to fail. Capture the raw bytes before any framework parser touches the body — for example express.raw({ type: "application/json" }).
Receiver checklist
- Read and preserve the raw HTTP body.
- Read
x-kidapay-signature. - Read
x-kidapay-timestamp. - Reject timestamps outside a five-minute window.
- Recalculate the HMAC SHA-256 signature.
- Compare signatures with a timing-safe comparison.
- Find the local order using
merchant_transaction_id. - Confirm
transaction_idmatches the stored reference. - Process the status idempotently.
- Return 200 immediately.
- Perform slower business operations asynchronously.
14. Webhooks — payloads
General body
{
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"merchant_transaction_id": "ORDER-10001",
"status": "SUCCESSFUL",
"payment_status": "PAID",
"amount": 25000,
"currency": "NGN",
"pay_currency": "USDT_BSC",
"pay_amount": 15.42,
"pay_network": "BSC",
"title": "Payment for Order 10001",
"description": "Two items from Example Store",
"callback_url": "https://merchant.example.com/payment/complete",
"webhook_url": "https://merchant.example.com/webhooks/kidapay"
}Event-specific values
{ "status": "SUCCESSFUL", "payment_status": "PAID" }{ "status": "PARTIALLY_PAID", "payment_status": "PARTIALLY_PAID" }{ "status": "CANCELLED", "payment_status": "CANCELLED" }{ "status": "NOT_PAID", "payment_status": "EXPIRED_PAYMENT" }Expired checkouts are processed by a scheduled monitor. Delivery may occur within approximately ten minutes after the checkout expiration time.
15. Webhooks — retries & idempotency
When a delivery fails, KidaPay retries on an exponential backoff schedule.
- ~5 minutes
- ~15 minutes
- ~45 minutes
The maximum configured retry count is three. Any HTTP 2xx response is treated as successful delivery.
Webhooks are delivered at-least-once. Use transaction_id + status + payment_status as your idempotency key, and store processed events so a duplicate is a no-op.
Do not depend on webhooks alone. Run a periodic reconciliation job against GET /v1/transactions and treat the API as the source of truth for any order still in a non-terminal state.
16. Errors
All errors share a common envelope. Some validation errors return the message as an array instead of a string.
{
"statusCode": 400,
"message": "Description of the error",
"timestamp": "2026-10-09T10:00:00.000Z",
"path": "/v1/transactions",
"requestId": "optional-request-id"
}{
"statusCode": 400,
"message": [
"amount must not be less than 0",
"currency must be one of the following values: KES, NGN, USD"
],
"error": "Bad Request",
"timestamp": "2026-10-09T10:00:00.000Z",
"path": "/v1/transactions"
}HTTP status codes
| Field | Type | Required | Description |
|---|---|---|---|
| 200 | OK | No | Request completed successfully. |
| 201 | Created | No | Resource created successfully. |
| 400 | Bad Request | No | Invalid input or unsupported operation. |
| 401 | Unauthorized | No | Missing or invalid API key. |
| 403 | Forbidden | No | Live access is not permitted. |
| 404 | Not Found | No | Requested resource was not found. |
| 409 | Conflict | No | State conflict, insufficient balance, or duplicate operation. |
| 500 | Server Error | No | Unexpected server error. |
Do not treat a timeout, network error, or 5xx response as a successful money-moving operation. Check the transaction state via GET /v1/transactions/{id} before retrying.