Quickstart
Take one test payment from start to finish: a key, a payment intent, the hosted checkout, a simulated outcome and the webhook that reports it. Nothing here moves money. No SDK is needed and none exists yet; every call is plain HTTPS and JSON.
The base URL is https://api.lemak.cash. Every request body and every response is JSON. Every POST needs an Idempotency-Key header that you choose (see Idempotency).
1. Get a test key
Our operators create your account; there is no self-service sign-up. Once you can sign in:
- Open
https://dash.lemak.cash/test. The/testprefix is test mode: every screen under it reads and writes test objects only. - Go to Developers and create an API key with mode
test. - Copy the key. It is shown once. We keep only its first sixteen characters and a SHA-256 digest, so no later screen can show it again. A lost key is revoked and replaced.
A test key starts with sk_test_, a live key with sk_live_. The key decides the mode of everything it touches: the payment intent, the balance it lands on, the events it emits and the webhook endpoints that receive them. See Testing.
2. Create a payment intent
Send the amount in minor units with its currency, your own order id, and the page the payer returns to. Leave payment_method out: the hosted checkout lets the payer choose. Route: POST /v1/payment_intents.
curl https://api.lemak.cash/v1/payment_intents \
-H "Authorization: Bearer sk_test_MFWZLFDEM5X3WDBPM7ULRVDKCE" \
-H "Idempotency-Key: 5EKP4SXV75NVPQ67TQWJCKI6TQ" \
-H "Content-Type: application/json" \
-d '{
"amount": 100000,
"currency": "THB",
"merchant_order_id": "ORD-1001",
"return_url": "https://shop.example/orders/1001",
"expires_in": 1800,
"metadata": { "cart": "c_8821" }
}'
const res = await fetch("https://api.lemak.cash/v1/payment_intents", {
method: "POST",
headers: {
"Authorization": "Bearer sk_test_MFWZLFDEM5X3WDBPM7ULRVDKCE",
"Idempotency-Key": "5EKP4SXV75NVPQ67TQWJCKI6TQ",
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 100000,
currency: "THB",
merchant_order_id: "ORD-1001",
return_url: "https://shop.example/orders/1001",
expires_in: 1800,
metadata: { cart: "c_8821" },
}),
});
const intent = await res.json();
The answer is 201 Created. With no payment_method the intent stops at created and carries the hosted page:
{
"amount": 100000,
"checkout_url": "https://checkout.lemak.cash/c/Y5FOKCTO66SXWC3IEQU7JPVDQY",
"created": "2026-10-16T08:14:02Z",
"currency": "THB",
"expires_at": "2026-10-16T08:44:02Z",
"fx": { "locked": false },
"id": "pi_AGQHAVEX7J327MRWOPILG46G6Y",
"merchant_order_id": "ORD-1001",
"metadata": { "cart": "c_8821" },
"mode": "test",
"object": "payment_intent",
"status": "created"
}
Keep id. Store checkout_url now: it is returned by this call and by no read of the intent, because we keep only a digest of the session token. return_url must be an absolute https URL on a host name; an IP literal or localhost is refused with 400 invalid_return_url, in test mode too. Reach a local server through an https tunnel.
The same request again with the same Idempotency-Key answers the same 201 with the stored body. The same key with a different body answers 422 idempotency_key_reused. A second intent with the same merchant_order_id answers 409 merchant_order_id_taken.
3. Send the payer to the checkout
Redirect the payer to checkout_url. The page is on checkout.lemak.cash, shows your name and mark, lets the payer pick a method and then shows the payment credential the channel returned. The page polls its own status and sends the payer to your return_url once the payment has succeeded.
The session lives as long as the intent's expires_at, or 30 minutes when you sent no expires_in. An expired page answers 410 checkout_session_expired and creates no attempt.
In test mode the channel is a simulator. The credential it returns is a QR code whose data starts with mock://pay/. No wallet can read it; the next step stands in for the payer.
4. Simulate the outcome
Once the payer has pressed pay on the hosted page, the intent holds a pending attempt. Tell the simulator what to report. Route: POST /v1/test/payment_intents/{id}/simulate.
curl https://api.lemak.cash/v1/test/payment_intents/pi_AGQHAVEX7J327MRWOPILG46G6Y/simulate \
-H "Authorization: Bearer sk_test_MFWZLFDEM5X3WDBPM7ULRVDKCE" \
-H "Idempotency-Key: Q2WJN4QF6XKZTA3Y7BRM5LDCVE" \
-H "Content-Type: application/json" \
-d '{ "outcome": "succeeded" }'
outcome is succeeded, failed or pending. The simulator answers with the callback a real channel would send, and that callback takes the same path a real one takes: it is stored, its signature is checked, and a worker picks it up within seconds. For succeeded the worker also asks the channel to confirm the amount, then moves the intent to succeeded. The response is the intent as it stood the moment the callback landed, so it may still read requires_payment; the final state arrives by webhook, or by polling GET /v1/payment_intents/{id}.
A live key is refused with 400 test_mode_required. An intent with no pending attempt, which is one the payer has not paid yet, answers 404 resource_missing. The Payments screen of the test dashboard has the same action as a button. More outcomes and options are in Testing.
5. Receive and verify the webhook
In the test dashboard, Developers, register a webhook endpoint. The address must be https on a public host name. The answer shows the endpoint's secret once; keep it. An endpoint created on the /test surface receives test events and no others.
Within seconds of step 4 we POST one event per status change to your endpoint, for example payment_intent.succeeded:
POST /hooks/lemak HTTP/1.1
Content-Type: application/json; charset=utf-8
X-Webhook-Signature: t=1760602449,v1=8f1c…e2a0,v2=q7Zt…Aw==
{"id":"evt_AGQHB2K3ZT6RXM4EQ7WPOILG5A","type":"payment_intent.succeeded","created":"2026-10-16T08:14:09Z","livemode":false,"data":{"id":"pi_AGQHAVEX7J327MRWOPILG46G6Y","merchant":"mch_AF4K2Q7NZP3TJWB6RE5LXDM2CY","mode":"test","object":"payment_intent","status":"succeeded"}}
Verify before you act. The signed string is the t value, a dot, and the exact body bytes. Check v1 with the endpoint secret:
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.WEBHOOK_SECRET; // the value shown once at registration
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks); // the raw bytes, never re-encoded
const fields = Object.fromEntries(
req.headers["x-webhook-signature"].split(",").map((f) => {
const i = f.indexOf("=");
return [f.slice(0, i).trim(), f.slice(i + 1)];
}),
);
const age = Math.abs(Date.now() / 1000 - Number(fields.t));
const want = Buffer.from(fields.v1, "hex");
const got = createHmac("sha256", SECRET)
.update(fields.t + ".").update(body).digest();
if (age > 300 || want.length !== got.length || !timingSafeEqual(want, got)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(body);
// Act once per event.id, then answer.
res.writeHead(200).end();
});
}).listen(8080);
Answer any 2xx within 10 seconds. Anything else is retried on a fixed schedule and the same event may arrive more than once, so record event.id and ignore a repeat. data names the object and its new status; read the full object with GET /v1/payment_intents/{id} rather than trusting a body that is only a notice. livemode is false for every test event.
That is the whole loop. Webhooks covers the second signature, which needs no secret, the retry schedule and the delivery log. Requests covers signing your own requests, versioning and errors.