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.
#QuickstartLive in 5 minutes. Get a key, create a charge, redirect the customer.
- 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.
- Call
/api/create-chargefrom your server. Get backpp_id(your reference) andpp_url(the hosted checkout). - Redirect the customer to
pp_url. They pay on our page, get returned to yourreturn_url. - 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
| Scope | Grants |
|---|---|
create_payment | Create charges via /api/create-charge |
verify_payment | Verify payments — /api/verify-payment and /api/verify-payments |
refund_payment | Refund 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.
create_paymentRequest body
| Field | Description |
|---|---|
full_name required | Customer's full name — shown on the receipt and stored on the customer record. |
amount required | A positive number as a string, e.g. "150" or "1500.50". Currency defaults to BDT. |
email_address required | Must be a valid email. Used for the receipt and to look up returning customers. |
mobile_number required | Customer's phone. Bangladeshi format works out of the box — 01XXXXXXXXX. |
currency optional | Defaults to "BDT". Must be one you've enabled in Brand Settings → Currency. |
return_url optional | Where the browser lands after checkout. Falls back to your brand's Default Return URL if omitted. Must be a full https:// URL. |
webhook_url optional | Where Payth sends server-to-server events. Falls back to your brand's Default Webhook URL if omitted. Same URL rules. |
metadata optional | Any 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.
- Server → Payth · your backend calls
create-charge, receivespp_id+pp_url. - Customer → Payth · you redirect the customer to
pp_url. Payth hosts the checkout, the customer picks a payment method, pays, sees a receipt. - Payth → Customer · Payth sends the customer back to your
return_url. Treat this only as a UX hint — never fulfill from the redirect alone. - Payth → Server · Payth
POSTs a webhook to yourwebhook_url. Reply200fast. - Server → Payth · your webhook handler calls
verify-paymentwith thepp_id. Only fulfill when the verify response saysstatus: "completed".
#Verify a paymentYour single source of truth. Everything else is a hint.
verify_paymentRequest
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.
| Status | What it means |
|---|---|
initiated | Charge exists. Customer has not paid yet. |
pending · processing | Payment submitted, awaiting confirmation. Do not fulfill. |
completed | Paid and confirmed. Safe to fulfill. |
canceled | Abandoned or rejected by the customer. Do not fulfill. |
expired | Went 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. |
refunded | Previously completed, now refunded. If you already fulfilled, reverse it. |
#Verify many at onceReconciliation, cron sweeps, order-status refresh.
verify_payment · up to 100 idscurl -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.
refund_paymentOnly 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.
| Field | Used when |
|---|---|
Default Return URL | Your create-charge request omits return_url. |
Default Webhook URL | Your 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.
- 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). - Set your default endpoints (optional). Under Brand Settings → API Setting → Endpoint Defaults, save your
return_urlandwebhook_url. This lets you skip both fields on everycreate-chargecall. Per-invoice values still win if you send them. - 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_idagainst your order id, then redirect the browser topp_url. That's it — Payth handles checkout, receipt, and gateway selection. - 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. - Set up the webhook receiver. Payth POSTs to your
webhook_urlwhenever a payment settles / refunds / cancels. Two headers ride along:X-Payth-TimestampandX-Payth-Signature. See Webhooks for the verify recipe. - 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 thepp_idto get authoritative status, (d) only mark the order paid + fulfill ifstatus === "completed", (e) reply200quickly (Payth retries non-2xx). - 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.
- 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 schemes — javascript:, 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." } }
| Code | What went wrong |
|---|---|
MISSING_FIELD | A required field (full_name, amount, mobile_number…) is absent or empty. |
INVALID_EMAIL | email_address is not a valid email. |
INVALID_AMOUNT | amount is not a positive number. |
INVALID_CURRENCY | currency is not enabled for your brand. |
INVALID_URL | return_url / webhook_url isn't a valid http(s):// URL, or — when domain enforcement is on — its domain isn't registered/active. |
INVALID_API_KEY | Key missing, invalid, expired, or disabled. |
INSUFFICIENT_SCOPE | Key exists but doesn't have the scope this endpoint needs. |
INVALID_PP_ID | The pp_id is missing or doesn't match any transaction. |
TOO_MANY_IDS | A verify-payments batch exceeded 100 ids. |
INVALID_JSON · INVALID_JSON_PAYLOAD | Request body is not valid JSON. |
INVALID_METADATA | metadata is present but not a JSON object. |
INVALID_CUSTOMER | The customer email is suspended by the admin. |
REFUND_NOT_SUPPORTED | The gateway that received this payment has no refund API. Pass "manual": true to record an out-of-band refund. |
REFUND_IN_PROGRESS | A refund on this pp_id is already in flight (concurrent-request guard). Retry in a moment. |
RATE_LIMITED | HTTP 429 — you exceeded 60 requests per minute per key. See Rate limits and check the Retry-After header. |