PAYTH API Reference Dashboard

Accept money in Bangladesh in a single POST.

One integration for bKash, Nagad, Rocket, BanglaQR, cards, and crypto. Create a charge from your server, redirect the customer to a hosted checkout, confirm the result with one call. That's the whole API.

Auth Header mhs-payth-api-key
Format JSON in, JSON out

#QuickstartLive in 5 minutes. Get a key, create a charge, redirect the customer.

  1. Create an API key. Open the dashboard, go to Brand Settings → API Setting, click New API Key, tick every scope you'll use (usually all three), copy it once.
  2. Call /api/create-charge from your server. Get back pp_id (your reference) and pp_url (the hosted checkout).
  3. Redirect the customer to pp_url. They pay on our page, get returned to your return_url.
  4. Confirm server-side with /api/verify-payment. Never trust the redirect or the webhook alone — always verify before you fulfill.
curl -X POST https://pay.kodelyth.com/api/create-charge \
  -H "mhs-payth-api-key: $PAYTH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "150",
    "currency": "BDT",
    "full_name": "Rahim Uddin",
    "email_address": "[email protected]",
    "mobile_number": "01700000000",
    "return_url": "https://yourstore.com/thanks",
    "metadata": { "order_id": "ORD-1001" }
  }'

# → { "status": true, "pp_id": "9513...", "pp_url": "https://pay.kodelyth.com/payment/9513..." }
$ch = curl_init('https://pay.kodelyth.com/api/create-charge');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'mhs-payth-api-key: ' . getenv('PAYTH_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount'        => '150',
        'currency'      => 'BDT',
        'full_name'     => 'Rahim Uddin',
        'email_address' => '[email protected]',
        'mobile_number' => '01700000000',
        'return_url'    => 'https://yourstore.com/thanks',
        'metadata'      => ['order_id' => 'ORD-1001'],
    ]),
]);
$res = json_decode(curl_exec($ch), true);
if (!empty($res['status'])) {
    header('Location: ' . $res['pp_url']);
    exit;
}
const res = await fetch('https://pay.kodelyth.com/api/create-charge', {
  method: 'POST',
  headers: {
    'mhs-payth-api-key': process.env.PAYTH_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: '150',
    currency: 'BDT',
    full_name: 'Rahim Uddin',
    email_address: '[email protected]',
    mobile_number: '01700000000',
    return_url: 'https://yourstore.com/thanks',
    metadata: { order_id: 'ORD-1001' },
  }),
});
const data = await res.json();
if (data.status) reply.redirect(data.pp_url);
import os, requests

r = requests.post(
    "https://pay.kodelyth.com/api/create-charge",
    headers={
        "mhs-payth-api-key": os.environ["PAYTH_KEY"],
        "Content-Type": "application/json",
    },
    json={
        "amount": "150",
        "currency": "BDT",
        "full_name": "Rahim Uddin",
        "email_address": "[email protected]",
        "mobile_number": "01700000000",
        "return_url": "https://yourstore.com/thanks",
        "metadata": {"order_id": "ORD-1001"},
    },
    timeout=15,
)
data = r.json()
if data.get("status"):
    redirect(data["pp_url"])

#AuthenticationEvery request carries your key in one header.

Get a key from Brand Settings → API Setting → New API Key. Grant one or more scopes and copy the key once — you can't see it again after you close the dialog.

mhs-payth-api-key: your_api_key_here
Content-Type: application/json
ScopeGrants
create_paymentCreate charges via /api/create-charge
verify_paymentVerify payments — /api/verify-payment and /api/verify-payments
refund_paymentRefund a completed charge via /api/refund-payment

Keep the key on the server. Never ship it in browser JavaScript, mobile bundles, or logs. If a key leaks, revoke it in the dashboard and issue a new one.

#Create a chargeAsk Payth for an invoice. Get back a hosted checkout URL.

POST/api/create-chargeScope create_payment

Request body

FieldDescription
full_name requiredCustomer's full name — shown on the receipt and stored on the customer record.
amount requiredA positive number as a string, e.g. "150" or "1500.50". Currency defaults to BDT.
email_address requiredMust be a valid email. Used for the receipt and to look up returning customers.
mobile_number requiredCustomer's phone. Bangladeshi format works out of the box — 01XXXXXXXXX.
currency optionalDefaults to "BDT". Must be one you've enabled in Brand Settings → Currency.
return_url optionalWhere the browser lands after checkout. Falls back to your brand's Default Return URL if omitted. Must be a full https:// URL.
webhook_url optionalWhere Payth sends server-to-server events. Falls back to your brand's Default Webhook URL if omitted. Same URL rules.
metadata optionalAny JSON object — echoed back on verify and webhook. Put your order_id, customer id, cart hash, whatever your system needs.

Shorthand: if you truly only have one contact channel, send email_mobile instead of the two fields — Payth fills the missing one with a placeholder. Passing both is strongly preferred so receipts and customer records are complete.

Response

{
  "status": true,
  "pp_id":  "951349812276208064312414842",
  "pp_url": "https://pay.kodelyth.com/payment/951349812276208064312414842"
}

Redirect the customer to pp_url. Store pp_id against your order — it's the handle you use to verify later.

#Checkout flowHow the four moving pieces fit together.

  1. Server → Payth · your backend calls create-charge, receives pp_id + pp_url.
  2. Customer → Payth · you redirect the customer to pp_url. Payth hosts the checkout, the customer picks a payment method, pays, sees a receipt.
  3. Payth → Customer · Payth sends the customer back to your return_url. Treat this only as a UX hint — never fulfill from the redirect alone.
  4. Payth → Server · Payth POSTs a webhook to your webhook_url. Reply 200 fast.
  5. Server → Payth · your webhook handler calls verify-payment with the pp_id. Only fulfill when the verify response says status: "completed".

#Verify a paymentYour single source of truth. Everything else is a hint.

POST/api/verify-paymentScope verify_payment

Request

curl -X POST https://pay.kodelyth.com/api/verify-payment \
  -H "mhs-payth-api-key: $PAYTH_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "pp_id": "951349812276208064312414842" }'

Response — every field

{
  "pp_id":            "951349812276208064312414842",
  "full_name":        "Rahim Uddin",
  "email_address":    "[email protected]",
  "mobile_number":    "01700000000",
  "gateway":          "bKash",
  "amount":           "150.00",
  "fee":              "0.00",
  "discount_amount":  "0.00",
  "total":            "150.00",
  "local_net_amount": "150.00",
  "currency":         "BDT",
  "local_currency":   "BDT",
  "metadata":         { "order_id": "ORD-1001" },
  "sender":           "01700000000",
  "transaction_id":   "TXN8F3K92A1",
  "status":           "completed",
  "date":             "Jul 26, 2026 08:15 PM"
}

An unknown pp_id returns HTTP 400 with { "error": { "code": "INVALID_PP_ID" } }.

#Payment statusFulfill on completed. Nothing else.

StatusWhat it means
initiatedCharge exists. Customer has not paid yet.
pending · processingPayment submitted, awaiting confirmation. Do not fulfill.
completedPaid and confirmed. Safe to fulfill.
canceledAbandoned or rejected by the customer. Do not fulfill.
expiredWent unpaid past its 60-minute window. Do not fulfill. Stop polling, but keep the order and your webhook handler alive — if the customer restarts, the same pp_id re-opens and can still complete and fire a normal completed webhook.
refundedPreviously completed, now refunded. If you already fulfilled, reverse it.

#Verify many at onceReconciliation, cron sweeps, order-status refresh.

POST/api/verify-paymentsScope verify_payment · up to 100 ids
curl -X POST https://pay.kodelyth.com/api/verify-payments \
  -H "mhs-payth-api-key: $PAYTH_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "pp_ids": ["951349...", "951350..."] }'

# → { "status": true, "count": 2, "results": [
#     { "pp_id": "951349...", "status": "completed", "amount": "150.00", ... },
#     { "pp_id": "951350...", "status": "not_found" }
#   ] }

#Refund a paymentReverse a completed charge — or record a manual refund handled outside Payth.

POST/api/refund-paymentScope refund_payment

Only a handful of gateways expose a real refund API. When they do, Payth performs the refund on the provider side and marks the transaction refunded. For every other gateway, pass "manual": true to record that you refunded it out-of-band (bKash wallet transfer, bank reversal, etc.).

curl -X POST https://pay.kodelyth.com/api/refund-payment \
  -H "mhs-payth-api-key: $PAYTH_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "pp_id": "9513..." }'

# → { "status": "refunded", "pp_id": "9513..." }
# Fails loudly with REFUND_NOT_SUPPORTED if the gateway has no refund API.
curl -X POST https://pay.kodelyth.com/api/refund-payment \
  -H "mhs-payth-api-key: $PAYTH_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "pp_id": "9513...", "manual": true }'

# Records the refund honestly:
# source_info: "Refund Mode: Manual (processed outside Payth)"

No silent fallback. Before we hardened this, non-refund-supporting gateways still returned {"status":"refunded"} even though no money had moved. Now the API rejects with REFUND_NOT_SUPPORTED unless you explicitly opt into "manual": true.

#WebhooksA server-to-server nudge that a payment changed.

When a payment settles, is refunded, or is canceled, Payth POSTs a JSON body to your webhook_url — the same shape as the verify-payment response above.

POST /your/webhook/path
Content-Type: application/json

{
  "pp_id":  "951349812276208064312414842",
  "status": "completed",
  "amount": "150.00",
  "total":  "150.00",
  "transaction_id": "TXN8F3K92A1",
  "metadata": { "order_id": "ORD-1001" }
}

Payth signs every webhook. Two headers ride along with the JSON body:

X-Payth-Timestamp: 1785600000
X-Payth-Signature: sha256=6f4c7d3a2f1e0b8a...

signature = HMAC-SHA256(webhook_secret, "<timestamp>.<raw json body>")

Grab your webhook_secret from Brand Settings → API Setting → Webhook Signing Secret. Store it in an env var the same way you store your API key.

Belt-and-suspenders is still smart. After validating the signature, still call /api/verify-payment with the incoming pp_id before fulfilling — that call is your one source of truth for status transitions (a signed webhook only proves it came from Payth, not that the payment is still in that state).

Reference implementation (PHP)

// STEP 1 — verify the HMAC signature (proves this POST really came from Payth)
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_PAYTH_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_PAYTH_SIGNATURE'] ?? '';

// reject replays older than 5 minutes
if (abs(time() - (int) $ts) > 300) { http_response_code(403); exit; }

$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, getenv('PAYTH_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) { http_response_code(403); exit; }

// STEP 2 — re-verify status (defense in depth; body could be a valid but stale event)
$body = json_decode($raw, true);
$ppId = $body['pp_id'] ?? '';

$ch = curl_init('https://pay.kodelyth.com/api/verify-payment');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'mhs-payth-api-key: ' . getenv('PAYTH_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['pp_id' => $ppId]),
]);
$v = json_decode(curl_exec($ch), true);

if (($v['status'] ?? '') === 'completed') {
    // mark the order paid, fulfill, send a receipt — but be idempotent:
    // the same webhook may arrive more than once.
}

http_response_code(200);

#Brand endpoint defaultsSkip sending return_url and webhook_url on every call.

Set them once under Brand Settings → API Setting → Endpoint Defaults. When a create-charge request omits either field, Payth uses your saved default. Per-invoice values always win when present.

FieldUsed when
Default Return URLYour create-charge request omits return_url.
Default Webhook URLYour create-charge request omits webhook_url.

Both must be full http(s):// URLs — the dashboard rejects anything else at save time.

#Integration walkthroughOne linear path from zero to accepting live payments. Skim it before you write code.

  1. Get your key + secret. In the Payth dashboard, open Brand Settings → API Setting → New API Key. Tick create_payment + verify_payment + refund_payment, then copy the key immediately — it's shown once. From Brand Settings → API Setting → Webhook Signing Secret, also copy the secret. Store both in your server's env vars (e.g. PAYTH_KEY, PAYTH_WEBHOOK_SECRET).
  2. Set your default endpoints (optional). Under Brand Settings → API Setting → Endpoint Defaults, save your return_url and webhook_url. This lets you skip both fields on every create-charge call. Per-invoice values still win if you send them.
  3. Build the create-charge call. When a customer clicks "Pay" on your site, POST to /api/create-charge with the customer's name/email/mobile + amount. Save the returned pp_id against your order id, then redirect the browser to pp_url. That's it — Payth handles checkout, receipt, and gateway selection.
  4. Handle the return. After the customer pays (or cancels), Payth sends them back to your return_url. Show them a "processing your payment…" screen. Don't trust the redirect as proof — wait for the webhook.
  5. Set up the webhook receiver. Payth POSTs to your webhook_url whenever a payment settles / refunds / cancels. Two headers ride along: X-Payth-Timestamp and X-Payth-Signature. See Webhooks for the verify recipe.
  6. In the webhook handler: (a) verify the signature with hash_equals, (b) reject events older than 5 minutes (replay protection), (c) call /api/verify-payment with the pp_id to get authoritative status, (d) only mark the order paid + fulfill if status === "completed", (e) reply 200 quickly (Payth retries non-2xx).
  7. Optionally reconcile on a cron. Once a day, batch-verify orders that are still in your DB as "awaiting" via /api/verify-payments — catches any webhook the internet ate.
  8. Test. Create a test charge, pay it (any gateway), watch your webhook fire, confirm your order updates. Then read the Errors table so you know what to show a merchant when things go sideways.

Integration is done when your webhook receiver: (1) verifies the signature, (2) re-calls verify-payment, (3) fulfills only on completed. Everything else is polish.

#Rate limits60 requests per minute per API key.

Every response — success or error — includes headers so you can back off cleanly:

X-RateLimit-Limit:     60
X-RateLimit-Remaining: 42
Retry-After:           37    // only on 429

When you exceed the limit you get an HTTP 429:

{ "error": { "code": "RATE_LIMITED", "message": "Too many requests. Slow down and try again in 37 seconds." } }

A single verify-payments call carrying up to 100 pp_ids counts as ONE hit — use it for reconciliation instead of looping verify-payment if you need many at once. If you hit the ceiling regularly for legitimate traffic, contact support to raise it.

#Domain whitelistOff by default. Turn on if you want extra containment.

Under System Settings → General → Domain whitelist enforcement. When on, every return_url and webhook_url domain must be pre-registered under Domains and marked active — anything else is rejected with INVALID_URL.

Regardless of this toggle, hostile URL schemesjavascript:, data:, file:, etc. — are always rejected at the gateway.

#ErrorsEvery failure is HTTP 4xx with a JSON body.

{ "error": { "code": "INVALID_URL", "message": "Return URL is invalid." } }
CodeWhat went wrong
MISSING_FIELDA required field (full_name, amount, mobile_number…) is absent or empty.
INVALID_EMAILemail_address is not a valid email.
INVALID_AMOUNTamount is not a positive number.
INVALID_CURRENCYcurrency is not enabled for your brand.
INVALID_URLreturn_url / webhook_url isn't a valid http(s):// URL, or — when domain enforcement is on — its domain isn't registered/active.
INVALID_API_KEYKey missing, invalid, expired, or disabled.
INSUFFICIENT_SCOPEKey exists but doesn't have the scope this endpoint needs.
INVALID_PP_IDThe pp_id is missing or doesn't match any transaction.
TOO_MANY_IDSA verify-payments batch exceeded 100 ids.
INVALID_JSON · INVALID_JSON_PAYLOADRequest body is not valid JSON.
INVALID_METADATAmetadata is present but not a JSON object.
INVALID_CUSTOMERThe customer email is suspended by the admin.
REFUND_NOT_SUPPORTEDThe gateway that received this payment has no refund API. Pass "manual": true to record an out-of-band refund.
REFUND_IN_PROGRESSA refund on this pp_id is already in flight (concurrent-request guard). Retry in a moment.
RATE_LIMITEDHTTP 429 — you exceeded 60 requests per minute per key. See Rate limits and check the Retry-After header.