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
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"
  }'
Security

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.

.env
KIDAPAY_API_KEY=test_your_api_key
KIDAPAY_BASE_URL=https://api-dev-sandbox.kidapay.com

2. Create a transaction

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

Node.js
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();
  },
);
Fulfilment rule

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 a prod_ key and point at the production base URL.

3. Authentication

Merchant integration endpoints use an API key in the x-api-key header.

Recommended headers
Content-Type: application/json
Accept: application/json
x-api-key: test_your_api_key
x-request-id: optional-unique-request-reference

Key types

FieldTypeRequiredDescription
test_*SandboxYesUsed with the sandbox base URL. A sandbox key against production, or vice versa, returns 401.
prod_*LiveYesUsed 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:

401
{
  "statusCode": 401,
  "message": "API key is missing.",
  "timestamp": "2026-10-09T10:00:00.000Z",
  "path": "/v1/transactions"
}

Invalid, inactive, or environment-incompatible key:

401
{
  "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:

403
{
  "statusCode": 403,
  "message": "You are not authorized to access live mode",
  "timestamp": "2026-10-09T10:00:00.000Z",
  "path": "/v1/transactions"
}
Backend only

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.

  1. A customer selects “Pay with Crypto” on the merchant's website.
  2. The merchant frontend calls the merchant's own backend.
  3. The merchant backend creates a KidaPay transaction using its API key.
  4. KidaPay returns a hosted payment_link.
  5. The merchant redirects the customer to the payment link.
  6. The customer selects a cryptocurrency and completes, cancels, or abandons the payment.
  7. KidaPay sends a signed webhook when the payment is successful, partially paid, cancelled, or expired.
  8. The merchant verifies the webhook signature and updates its local order.
  9. 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

FieldTypeRequiredDescription
payment_linkURLYesReturned by the create endpoint. The customer's browser goes here to pay.
callback_urlURLNoWhere the customer's browser goes after payment completes or is cancelled. Not a fulfilment signal.
webhook_urlURLNoYour server endpoint. KidaPay POSTs signed payment notifications here. This is the fulfilment signal.
Fulfilment rule

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.

NEWThe transaction was created and is awaiting payment.
SUCCESSFULThe required payment was received.
PARTIALLY_PAIDPayment was received, but it was below the required amount.
NOT_PAIDThe checkout expired without receiving the required payment.
CANCELLEDThe customer or checkout flow cancelled the payment.
FAILEDTransaction processing failed.
REFUNDEDThe transaction was refunded.

Transitions

FieldTypeRequiredDescription
NEW → SUCCESSFULhappy pathNoFull amount received.
NEW → PARTIALLY_PAIDunderpaymentNoReceived less than required. May still become SUCCESSFUL if topped up.
NEW → CANCELLEDcustomer actionNoCustomer or checkout flow cancelled the payment.
NEW → NOT_PAIDexpiryNoCheckout expired without any payment.
NEW → FAILEDprocessing errorNoTreat as terminal.
SUCCESSFUL → REFUNDEDmerchant actionNoOnly reachable from SUCCESSFUL.
Partial payments are not terminal

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.

Sandbox

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.

Production

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.

POST/v1/transactions

Request fields

FieldTypeRequiredDescription
merchant_transaction_idstringYesUnique merchant-generated transaction reference. Must be unique per environment.
amountnumberYesFiat amount to be paid.
currencystringYesFiat currency: NGN, KES, or USD.
customer_emailstringNoCustomer email address.
pay_currencystringNoExact cryptocurrency identifier, such as USDT_BSC.
titlestringNoTransaction title displayed at checkout.
descriptionstringNoTransaction description.
callback_urlURLNoBrowser destination after payment completion or cancellation.
webhook_urlURLNoMerchant 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

JSON
{
  "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

201
{
  "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

400
{
  "statusCode": 400,
  "message": "Transaction with merchant_transaction_id ORDER-10001 already exists",
  "timestamp": "2026-10-09T10:00:00.000Z",
  "path": "/v1/transactions"
}
Retry safely

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.

GET/v1/transactions/{transaction_id}
cURL
curl \
  --url https://api-dev-sandbox.kidapay.com/v1/transactions/550e8400-e29b-41d4-a716-446655440000 \
  --header "x-api-key: test_your_api_key"

Response

200
{
  "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.

GET/v1/transactions?page=1&limit=10

Query parameters

FieldTypeRequiredDescription
pagenumberNoPage number. Default: 1
limitnumberNoNumber of transactions per page. Default: 10
cURL
curl \
  --url "https://api-dev-sandbox.kidapay.com/v1/transactions?page=1&limit=10" \
  --header "x-api-key: test_your_api_key"

Response

200
{
  "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

Preconditions

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

POST/v1/refund/calculate

Returns the exact refund amount, fee, total deduction, and whether your balance covers it. Call this before creating the refund.

Full refund
{
  "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
  "refund_type": "FULL",
  "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
}
Partial refund
{
  "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
  "refund_type": "PARTIAL",
  "pay_amount": 5.25,
  "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"
}

Calculate response

200
{
  "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

POST/v1/refund

Same shape as calculate, plus a free-text reason for your records.

Full refund request
{
  "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
  "refund_type": "FULL",
  "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
  "reason": "Customer requested cancellation"
}
Partial refund request
{
  "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
  "refund_type": "PARTIAL",
  "pay_amount": "5.25",
  "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
  "reason": "One item was unavailable"
}
Wait for on-chain confirmation

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.

POST/v1/withdraw

Request fields

FieldTypeRequiredDescription
addressstringYesDestination blockchain address.
pay_currencystringYesExact cryptocurrency identifier, such as USDT_BSC.
pay_amountstringYesRequested amount represented as a string.
pay_amount_currencystringYesThe same cryptocurrency or NGN, KES, or USD.

Request

JSON
{
  "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
  "pay_currency": "USDT_BSC",
  "pay_amount": "25.00",
  "pay_amount_currency": "USDT_BSC"
}

Response

HTTP 201 Created

201
{
  "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.

Do not fulfil from a redirect

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.

Headers
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

JavaScript
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);
}
Use the raw body

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

  1. Read and preserve the raw HTTP body.
  2. Read x-kidapay-signature.
  3. Read x-kidapay-timestamp.
  4. Reject timestamps outside a five-minute window.
  5. Recalculate the HMAC SHA-256 signature.
  6. Compare signatures with a timing-safe comparison.
  7. Find the local order using merchant_transaction_id.
  8. Confirm transaction_id matches the stored reference.
  9. Process the status idempotently.
  10. Return 200 immediately.
  11. Perform slower business operations asynchronously.

14. Webhooks — payloads

General body

JSON
{
  "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

Successful payment
{ "status": "SUCCESSFUL", "payment_status": "PAID" }
Partial payment
{ "status": "PARTIALLY_PAID", "payment_status": "PARTIALLY_PAID" }
Cancelled payment
{ "status": "CANCELLED", "payment_status": "CANCELLED" }
Expired payment
{ "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.

Handle duplicates

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.

Reconciliation is the safety net

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.

Error shape
{
  "statusCode": 400,
  "message": "Description of the error",
  "timestamp": "2026-10-09T10:00:00.000Z",
  "path": "/v1/transactions",
  "requestId": "optional-request-id"
}
Validation error
{
  "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

FieldTypeRequiredDescription
200OKNoRequest completed successfully.
201CreatedNoResource created successfully.
400Bad RequestNoInvalid input or unsupported operation.
401UnauthorizedNoMissing or invalid API key.
403ForbiddenNoLive access is not permitted.
404Not FoundNoRequested resource was not found.
409ConflictNoState conflict, insufficient balance, or duplicate operation.
500Server ErrorNoUnexpected server error.
Retries and money

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.