Requests

The rules every call follows: how a request is authenticated and, if you choose, signed; how a retry is made safe with Idempotency-Key; how a version is chosen; what an error looks like; how lists page; and the shapes of money, time and identifiers.

Base URL https://api.lemak.cash. Bodies are JSON with Content-Type: application/json, at most 64 KiB; a larger body answers 413 request_too_large. Responses are JSON. Every response carries a Request-Id header, and an error repeats the same value as request_id; keep it with any problem you report.

Authentication

Every /v1 route takes your API key as a bearer token and nothing else: no cookie, no query parameter.

Authorization: Bearer sk_test_MFWZLFDEM5X3WDBPM7ULRVDKCE

A key is sk_test_ or sk_live_ plus 26 characters from crypto/rand. Its mode is the mode of every object the request touches. A missing, unknown or revoked key answers 401 invalid_api_key, one answer for every cause. Keys are created and revoked in the dashboard under Developers; the plaintext is shown once at creation.

Your account may carry an IP allow-list, set in the dashboard under Settings as CIDR prefixes. With a list in place, a request from any other address answers 403 source_not_allowed before anything else is read. An empty list allows every source.

Request signing (optional)

A bearer key alone means a stolen key is a working key. You can require that every request also carries a signature your servers alone can produce. Choose the scheme under Settings, signature_scheme:

none
The default. The headers below are ignored.
hmac_sha256
Every request carries X-Timestamp and X-Signature: v1=…, an HMAC-SHA256 under a per-account secret.
ed25519
Every request carries X-Timestamp, X-Key-Id and X-Signature: v2=…, an Ed25519 signature under a key pair you generate. Only the public half ever reaches us.

The scheme is a floor. Once ed25519 is set, an unsigned request and an HMAC-signed request are both refused. Set the scheme after your client signs, not before.

What is signed

Both schemes sign the same string, four lines joined by \n with no trailing newline:

<X-Timestamp>
<METHOD>
<path and query, as sent>
<hex SHA-256 of the body>
  • X-Timestamp is a Unix time in seconds. A request more than 300 seconds from our clock, either way, is refused.
  • The method is upper case. The path is the request target including the query string, exactly as it goes on the wire: /v1/payment_intents?limit=10.
  • The body hash is the lower-case hex SHA-256 of the body bytes. A request with no body hashes the empty string: e3b0c442…b855.
  • The signature value is standard base64 with padding, with no whitespace. One signature has one spelling; any other spelling is refused.

Every /v1 request is checked, GET included. A failure of any kind, missing, malformed, wrong, stale or already used, answers 401 invalid_signature with param: "X-Signature".

Replay protection, and why a retry must re-sign

We record every accepted (timestamp, signature) pair for the length of its window. A second request with the same pair is refused as a replay, even with a valid signature. Therefore a retry must take a fresh X-Timestamp and compute a fresh signature, while it keeps the same Idempotency-Key, which is what makes the retry safe. If the replay store cannot answer, the request is refused with 503 service_unavailable rather than accepted unchecked; retry it.

Ed25519

Generate an Ed25519 key pair. Register the public key, 32 raw bytes in base64, in the dashboard under Developers; it calls POST /dashboard/v1/signing_keys. The answer carries an id of the form key_…; send it as X-Key-Id. Several keys may be live at once, so a rotation is: register the new key, switch the client, revoke the old key. Revocation takes effect at once, which is the answer to a leaked private key.

import { createHash, sign } from "node:crypto";

// privateKey: a KeyObject for your Ed25519 private key. keyId: the key_… id you were given.
async function signedFetch(method, path, body, { apiKey, keyId, privateKey, idempotencyKey }) {
  const bytes = body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(body));
  const ts = Math.floor(Date.now() / 1000).toString();     // fresh on every attempt
  const canonical = [ts, method, path, createHash("sha256").update(bytes).digest("hex")].join("\n");
  const signature = sign(null, Buffer.from(canonical), privateKey).toString("base64");

  return fetch("https://api.lemak.cash" + path, {
    method,
    headers: {
      "Authorization": "Bearer " + apiKey,
      "Content-Type": "application/json",
      "X-Timestamp": ts,
      "X-Key-Id": keyId,
      "X-Signature": "v2=" + signature,
      ...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
    },
    body: body === undefined ? undefined : bytes,
  });
}

HMAC-SHA256

The signature is v1= plus the base64 HMAC-SHA256 of the canonical string under your account's signing secret, with no X-Key-Id:

import { createHmac } from "node:crypto";
// secret: the 64 hex characters the dashboard shows. The key is the 32 bytes they encode.
const signature = createHmac("sha256", Buffer.from(secret, "hex")).update(canonical).digest("base64");
// X-Signature: "v1=" + signature

Get the secret with POST /dashboard/v1/signing_secret/reveal from a signed-in dashboard session, with the one-time code of your authenticator app as {"totp": "123456"}. It needs the owner, admin or developer role, and a wrong code answers 401 invalid_credentials. The Developers screen does the same with Reveal signing secret on its signing keys tab. Every reveal is written to the audit log, without the value. The secret is derived per account from our master key and is never stored, so you can reveal it again at any time. One secret serves your test and live keys alike.

The secret cannot be rotated yet. If you think it has leaked, move to ed25519: its keys are revoked one by one, at once.

Idempotency-Key

Every /v1 POST requires an Idempotency-Key header, up to 255 characters, that you choose; a random string per request is right. A POST without one answers 400 idempotency_key_required.

The key is claimed by one atomic insert when the request arrives, so two identical requests racing each other cannot both proceed. What then happens depends on what the key already names:

Same key, and…Answer
the first request has finishedIts stored status and body, byte for byte, at the version it was answered at. A 4xx is stored and replayed too.
the first request is still runningWe wait up to 10 seconds for it, then 409 request_in_progress. Retry after a moment with the same key.
a different body, a different path, or the other mode422 idempotency_key_reused. The key is scoped to your account and the request digest covers the mode, the method, the path as sent and the body, so a test key can never replay a live answer and one key on two intents is refused rather than answered with the first.
the first request answered 5xxNothing was stored. The retry is a first request and never a second charge.
the first request died mid-wayAfter 30 seconds its lock is stale; exactly one retry takes it over and resumes from where it stopped, so a crash does not burn your key.

Keys do not expire. Use a fresh key for every distinct request, including two cancels of two different intents, and reuse a key only to retry the very same request.

Api-Version

Versions are dates, YYYY-MM-DD. Your account is pinned to the current version on its first request and stays there until you move it. One request can name another version with the header:

Api-Version: 2026-09-01

A version we do not serve answers 400 invalid_api_version, header or pin. An idempotent replay answers at the version of the first request, whatever the retry names. Webhook endpoints carry their own pinned version; see Webhooks. One version exists today, 2026-09-01, so the header is optional and changes nothing yet. A breaking change will arrive as a new date, and your pin will keep the old shape until you choose to move. There is not yet a self-service action to move an account's pin.

Errors

Every failure is one envelope:

{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was already used with a different request body.",
    "param": "Idempotency-Key",
    "request_id": "req_ZQ5MD7DUT4E2WZAOOTBOHNCJIQ"
  }
}
type
One of invalid_request_error, authentication_error, idempotency_error, rate_limit_error, api_error.
code
A stable string a program branches on. The message may change; the code does not.
message
For a person. Never a stack trace, a constraint name or a driver message.
param
The field or header at fault, when there is one.
request_id
Quote it, with the Request-Id response header, when you report a problem.
StatusCodes
400A bad parameter: amount_below_minimum, invalid_currency, invalid_return_url, payment_method_required, idempotency_key_required, invalid_json, invalid_api_version, invalid_identifier, invalid_limit, rate_unavailable, test_mode_required, simulation_unsupported.
401invalid_api_key; invalid_signature.
402no_channel_available: no channel serves this amount, method and currency.
403source_not_allowed; settlement_schedule_not_manual.
404resource_missing. Another merchant's object is also 404, never 403.
409request_in_progress; merchant_order_id_taken; invalid_state_transition; nothing_to_settle.
410checkout_session_expired. Nothing was charged.
413request_too_large: the body is over 64 KiB.
422idempotency_key_reused.
429rate_limit_exceeded, with Retry-After.
503service_unavailable, with Retry-After. Retry with the same Idempotency-Key.
500api_error, with nothing else. Never stored against your key, so a retry is safe.

Pagination

A list answers { "object": "list", "data": [...], "has_more": true }, newest first. Page with two query parameters:

limit
1 to 100, default 20. Anything else is 400 invalid_limit.
starting_after
The id of the last object on the page you have. The next page starts after it.
curl "https://api.lemak.cash/v1/payment_intents?limit=50&starting_after=pi_AGQHAVEX7J327MRWOPILG46G6Y" \
  -H "Authorization: Bearer sk_test_MFWZLFDEM5X3WDBPM7ULRVDKCE"

Stop when has_more is false. Payment intents also filter on status, merchant_order_id and created[gte]; disputes on status. The attempts of one intent are the one list with no cursor: it is short, and oldest first, because that is the order they were tried in.

Money, time and identifiers

Money
An integer amount in minor units beside a three-letter ISO 4217 currency: { "amount": 100000, "currency": "THB" } is 1,000.00 THB. There is no decimal anywhere on the wire. An exchange rate is a decimal string, never a number.
Time
RFC 3339 in UTC with a Z: 2026-10-16T08:14:02Z. Durations you send, such as expires_in, are seconds.
Identifiers
A prefix, an underscore and base32 in the alphabet A–Z and 2–7: pi_ payment intent, pa_ attempt, dp_ dispute, st_ settlement, evt_ event, whe_ webhook endpoint, key_ signing key, req_ request. An id is safe in a path and a log. It is not a secret and encodes its creation time; anything that must be unguessable, such as a key or a checkout token, is random and is never derived from an id.
Metadata
An object of string keys to string values, stored and returned as sent.

Rate limits

Each API key has a token bucket of 100 requests per second with a burst of 200. A test key and a live key have separate buckets. Over the limit answers 429 rate_limit_exceeded with a Retry-After header in seconds; wait that long and retry. A request refused by your IP allow-list does not spend the quota.