Get started
Authentication
Send your API token on every request. The header form is preferred, because a token in a query string ends up in proxy and web-server access logs:
curl -H "X-Api-Token: <your-token>" \
"https://apipe.in/api/corp/v1/balance"Two transports are accepted, in this order of preference:
X-Api-Token: <token>— recommendedAuthorization: Bearer <token>
IP allowlist
Your account is restricted to the source IP addresses you register with us (individual addresses or CIDR ranges). A call from anywhere else is refused with result code 2428. Tell us before you change egress addresses, or your traffic stops.
Rate limit
Requests are capped per minute per account, in two budgets: every GET (status checks, balance, lists) has its own, never larger than your account's limit, so checking statuses never uses up the budget of your money calls. Exceeding a budget returns 429 with result code 123; retry after a short pause. On callback version 3, a GET refused this way also carries Retry-After: the seconds until you may call again.
To follow a pending transaction, check it first about 30 seconds after the reply, then once a minute — or let the callback tell you.
Request signing
Recharge (with its status, balance, operators, services and complaints) works with your API token alone, or signed: your choice. Bill payments, money transfer, bus and AEPS need every request signed with your signing secret once they are switched on for your account.
Your API token says who you are; the signature proves the request came from you and was not changed or replayed on the way. It covers the method, the path, the query, the body, a timestamp and a single-use nonce, so a copied token or a captured request is useless on its own. Signing comes on top of the token and the IP allowlist; it replaces neither.
- In the client portal, open API → Credentials and press Create signing secret. It starts with
sig_and is shown once: keep it on your server only. - For every request, build the string to sign and its HMAC-SHA256, as described below.
- Send these headers with every signed request, status checks included:
| Header | Value |
|---|---|
X-Api-Token | Your API token, as today. |
X-Api-Timestamp | The current Unix time in seconds (UTC). It must be within 5 minutes of our clock, so keep your servers on NTP. |
X-Api-Nonce | A new random value for every request, up to 64 characters (32 hex characters is a good choice). We refuse a nonce we have already seen. |
X-Api-Signature | HMAC-SHA256 of the string to sign, keyed with your whole signing secret (the sig_ prefix included), as 64 lowercase hex characters. |
Signing: the string to sign
Six lines joined with a newline (\n), with no newline after the last one:
METHOD
/path
canonical-query
sha256-of-body
timestamp
nonce| Line | What goes in it |
|---|---|
METHOD | GET or POST, in capitals. |
/path | The path only, for example /api/corp/v1/recharge: no host, no query string, no slash at the end. |
canonical-query | Every query parameter except token, sorted by name. Write each one as name=value, with the name and the value percent-encoded as in RFC 3986: letters, digits and - _ . ~ stay as they are, everything else becomes %XX, and a space is %20 (never +). Join them with &. With no query, this line is empty. |
sha256-of-body | The SHA-256 of the exact body bytes you send, as 64 lowercase hex characters. A request with no body (a GET) uses the SHA-256 of nothing: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. |
timestamp | The value of X-Api-Timestamp. |
nonce | The value of X-Api-Nonce. |
Signing: worked examples
Check your code against these two before you sign a live request. The secret is an example: never use it.
Signing secret sig_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
Request POST /api/corp/v1/recharge
Body {"account":"9876543210","operator":"LIVJIO00003","amount":"10","urid":"ORD-1001"}
X-Api-Timestamp 1760432400
X-Api-Nonce 6f1d2c9a8b7e4f3a9c0d1e2f3a4b5c6dThe string to sign (the third line is empty, because there is no query):
POST
/api/corp/v1/recharge
5396cfdec8ae1c209378e43d871c55fa8b7c57f9594f647a643b1293c7be55a0
1760432400
6f1d2c9a8b7e4f3a9c0d1e2f3a4b5c6dThe header to send: X-Api-Signature: f6ec75ec5c07ef8fe831a835af79e2710cc0a7a8d1e076b41a9021afa5c05434
The same secret and timestamp on GET /api/corp/v1/recharge?urid=ORD-1001 with the nonce 0a1b2c3d4e5f60718293a4b5c6d7e8f9:
GET
/api/corp/v1/recharge
urid=ORD-1001
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1760432400
0a1b2c3d4e5f60718293a4b5c6d7e8f9The header to send: X-Api-Signature: 1ef42d4de5c0ee8c1a0a2c0b56ed9ce5ee5767f7f539b9f76ec8213628cda1d5
Signing: code
The same function in five languages. Each one gives exactly the signatures of the examples above.
function corpSign(string $method, string $path, array $query, string $body,
string $timestamp, string $nonce, string $signingSecret): string
{
unset($query['token']); // the API token is never part of the signature
ksort($query, SORT_STRING);
$canonicalQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$stringToSign = implode("\n", [
strtoupper($method),
'/' . trim($path, '/'),
$canonicalQuery,
hash('sha256', $body), // the exact bytes you send ('' for a GET)
$timestamp,
$nonce,
]);
return hash_hmac('sha256', $stringToSign, $signingSecret);
}
// A NEW timestamp and nonce for every request — retries included.
$body = json_encode(['account' => '9876543210', 'operator' => 'LIVJIO00003',
'amount' => '10', 'urid' => 'ORD-1001']);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$signature = corpSign('POST', '/api/corp/v1/recharge', [], $body, $timestamp, $nonce, $signingSecret);
// Send exactly $body, with the headers X-Api-Token, X-Api-Timestamp,
// X-Api-Nonce and X-Api-Signature.import hashlib
import hmac
from urllib.parse import quote
def corp_sign(method, path, query, body, timestamp, nonce, signing_secret):
"""body: the exact bytes you send (b"" for a GET)."""
pairs = sorted((k, v) for k, v in query.items() if k != "token")
canonical_query = "&".join(
quote(str(k), safe="-_.~") + "=" + quote(str(v), safe="-_.~") for k, v in pairs
)
string_to_sign = "\n".join([
method.upper(),
"/" + path.strip("/"),
canonical_query,
hashlib.sha256(body).hexdigest(),
timestamp,
nonce,
])
return hmac.new(signing_secret.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest()const crypto = require('crypto');
// RFC 3986: encodeURIComponent leaves ! ' ( ) * as they are, so encode those too.
const rfc3986 = (s) => encodeURIComponent(String(s))
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
// body: the exact string or Buffer you send ('' for a GET)
function corpSign(method, path, query, body, timestamp, nonce, signingSecret) {
const canonicalQuery = Object.keys(query).filter((k) => k !== 'token').sort()
.map((k) => rfc3986(k) + '=' + rfc3986(query[k])).join('&');
const stringToSign = [
method.toUpperCase(),
'/' + path.replace(/^\/+|\/+$/g, ''),
canonicalQuery,
crypto.createHash('sha256').update(body).digest('hex'),
timestamp,
nonce,
].join('\n');
return crypto.createHmac('sha256', signingSecret).update(stringToSign).digest('hex');
}import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.Map;
import java.util.StringJoiner;
import java.util.TreeMap;
public final class CorpSigner {
// RFC 3986 from URLEncoder: space is %20, * is %2A, ~ stays as it is.
static String rfc3986(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8)
.replace("+", "%20").replace("*", "%2A").replace("%7E", "~");
}
static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
/** body: the exact bytes you send (an empty array for a GET). Java 11+. */
public static String sign(String method, String path, Map<String, String> query, byte[] body,
String timestamp, String nonce, String signingSecret) throws Exception {
StringJoiner canonicalQuery = new StringJoiner("&");
for (Map.Entry<String, String> e : new TreeMap<>(query).entrySet()) {
if (!e.getKey().equals("token")) {
canonicalQuery.add(rfc3986(e.getKey()) + "=" + rfc3986(e.getValue()));
}
}
String stringToSign = String.join("\n",
method.toUpperCase(Locale.ROOT),
"/" + path.replaceAll("^/+|/+$", ""),
canonicalQuery.toString(),
hex(MessageDigest.getInstance("SHA-256").digest(body)),
timestamp,
nonce);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
}
}SECRET='sig_…your signing secret…'
TOKEN='…your API token…'
BODY='{"account":"9876543210","operator":"LIVJIO00003","amount":"10","urid":"ORD-1001"}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIG=$(printf 'POST\n/api/corp/v1/recharge\n\n%s\n%s\n%s' "$BODY_HASH" "$TS" "$NONCE" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "https://apipe.in/api/corp/v1/recharge" \
-H "Content-Type: application/json" \
-H "X-Api-Token: $TOKEN" \
-H "X-Api-Timestamp: $TS" \
-H "X-Api-Nonce: $NONCE" \
-H "X-Api-Signature: $SIG" \
--data "$BODY"Signing: retries, refusals and a new secret
- Every attempt gets a new timestamp and a new nonce, retries included. Keep the same
uridorrequest_id(see Retries and idempotency), but sign the retry afresh: a nonce we have already seen is refused. - A refused signature is not a failed transaction. If a retry is refused with
invalid_signature, your first attempt may still have gone through. Look the transaction up by your reference before you do anything else, and never refund your customer because of aninvalid_signaturerefusal. - A missing, wrong, stale or replayed signature is refused with
HTTP 401:
{
"status": "rejected",
"error": "invalid_signature",
"code": 113,
"message": "Request signature missing, stale, replayed or invalid.",
"urid": "ORD-1001"
}- A new secret: press Replace signing secret on the same card. The old secret keeps working for 24 hours, so you can roll the new one out without stopping. Replace it at once if it may have leaked.
- The secret never travels: not in a request, not in a browser or a mobile app, not in an email. Our team will never ask you for it.
Signing: which calls must be signed
- Recharge, its status, balance, operators, services and complaints: the API token alone or a signed request — your choice, for every account, new or existing. If you send a signature, it is checked: a wrong one is refused, never ignored.
- Bill payments, money transfer, bus and AEPS (and any service added later): always signed, once the service is switched on for your account. A call with the token alone is then refused with
HTTP 401andinvalid_signature, and themessagesays how to sign. A service that is not switched on for your account answersservice_not_enabled(code331), signed or not. - Signing never changes anything for your other calls. After you start signing bill payments, you can still recharge with the token alone.
- If you already send an
X-Api-Signaturemade with your API token (the earlier scheme), it is still accepted on recharge. The other services accept only a signature made with your signing secret. - Our team can require signed requests on every service for your account, recharge included — for example after a key may have leaked.
Errors
A refusal of your credentials — the token, the signature or the source IP — carries a real HTTP status plus a body like:
{
"status": "rejected",
"error": "ip_not_allowed",
"code": 2428,
"message": "IP Validation failed."
}A new recharge, bill payment, bus booking or money transfer refused for an account or service reason — the service is not switched on for your account; the account is paused, inactive, or waiting on KYC or the service agreement; your wallet is suspended; the service or the recharge operator is switched off; or the service is closed for the night — answers HTTP 200 with "ok":false, "status":"failed", the reason in error and message, and an empty order_id. It has the same fields on every service. Nothing was created and nothing was charged: file it with your other failures. Your own urid or request_id is echoed back.
{
"ok": false,
"status": "failed",
"code": 331,
"order_id": "",
"urid": "ABC123",
"operator_ref": null,
"amount": "100",
"account": "9876543210",
"balance": null,
"message": "Your KYC is not verified yet. Complete it in the client portal before transacting.",
"error": "kyc_not_verified"
}On POST /recharge, the refusals the recharge endpoint gives itself — recharge not switched on for your account, your wallet suspended, the operator or recharge switched off, a daily limit — come back as an ordinary failed recharge: the same fields as any recharge reply (no ok or error), "status":"failed", an empty order_id and the reason in message.
These requests do not get "status":"failed", because it would describe a transaction that already exists:
- A
GET(a status check) and aPOSTto/complaints. For an account reason they getHTTP 403with"status":"rejected", as before. - A retry of a reference that already has a transaction (a
urid,request_id, transferref,block_refor billfetch_refwe have already acted on). On recharge, a retry with the same details gets that transaction's own result, like any retry: when recharge is switched off for your account, and also while your account is paused, inactive, or waiting on KYC or the service agreement (then withbalancenull). Otherwise an account reason, or a service not switched on for your account, answersHTTP 403with"status":"rejected". On bill payments, bus and money transfer, any other refusal of a retry comes back as that service's own error reply, with a4xxor503HTTP status anderrorandmessage. Look the transaction up by its reference. Status checks stay open when a service is switched off for your account; while the account itself is refused they are refused too, until it is restored.
Any other refusal — a mistake in the request, a duplicate, not enough balance — keeps its 4xx status: "status":"rejected" on recharge, the service's own error reply elsewhere.