Signing requests
Every authenticated call carries a timestamp, a single-use nonce and an HMAC signature; every request body is AES-256-GCM encrypted.
The four headers
An API key alone will not get you a payment. Every authenticated endpoint checks four headers, in this order, and refuses the request on the first one that does not hold:
X-Api-Key: ecpay_test_… X-Ecash-Timestamp: 1786694400 X-Ecash-Nonce: 9f2c1b7e4a0d5836 X-Ecash-Signature: t=1786694400,v1=4c1f… (64 lower-case hex characters)
X-Api-Key— which account is calling, and which environment the call runs in. See Environments and API keys.X-Ecash-Timestamp— unix seconds, not milliseconds. Must be within 300 seconds of our clock.X-Ecash-Nonce— any string you have not used before. A UUID is ideal.X-Ecash-Signature—t=<timestamp>,v1=<hmac>. Thetmust equal the timestamp header.
The single public endpoint — GET /v1/payments/server-time — needs none of them, which is what makes it usable for diagnosing the other four.
Where the key pair comes from
Signing needs two secrets beyond your API key: a signing secret (the HMAC key) and an encryption key (32 bytes, given to you as 64 hex characters). They are one pair per account and they cover both environments.
Unlike API keys, you do not issue this pair yourself. It is minted for your account and handed to you once; the dashboard will tell you whether a pair exists and when it was last rotated, but it will never show you the values again — they are stored encrypted and are not readable after minting, by you or by us. If you do not have yours, ask support before you start writing the client.
Step 1 — Seal the body
If your request carries a body, that body is encrypted, not sent as JSON. AES-256-GCM under your encryption key, framed like this:
plaintext = JSON.stringify(body) // {"referenceId":"order-1001",…}
iv = 12 random bytes
ciphertext, tag = AES-256-GCM(encryptionKey, iv, plaintext)
rawBody = base64( iv ‖ tag ‖ ciphertext ) // 12 bytes ‖ 16 bytes ‖ restTwo details catch people out. The IV goes first and the 16-byte authentication tag goes second, before the ciphertext — many libraries hand you ciphertext ‖ tag and expect you to split it, as both clients below do. And the value you send as the request body is the base64 string itself, not a JSON object wrapping it.
Send it with Content-Type: application/octet-stream: the body is ciphertext, not JSON, and declaring it as JSON invites whatever sits in front of the API to try to parse it.
Step 2 — Sign what goes on the wire
The signature covers the bytes on the wire — the sealed base64 — and not the plaintext. That order is deliberate: we verify who you are before decrypting anything you sent, so a forged body is rejected without ever being opened.
signature = HMAC_SHA256(signingSecret, timestamp + "." + nonce + "." + rawBody) where rawBody is the EXACT bytes you put on the wire: · a request with a body → the base64 string from step 1 · a request without one → the empty string
Note the two literal dots, and note that a body-less request still signs "<timestamp>.<nonce>." with nothing after the final dot. The digest is lower-case hex; the comparison is constant-time, so a wrong signature tells you nothing about the right one.
Clocks, nonces and replays
Three rules, and each has its own error code:
- Clock. Your timestamp must be within 300 seconds of ours, in either direction, or you get
SIGNATURE_TIMESTAMP_OUT_OF_RANGE. ReadGET /v1/payments/server-timeinstead of trusting the host clock — it returnsunix,isoand the tolerance itself. - Nonce. Each one is accepted once and remembered for those same 300 seconds. Reuse gets
SIGNATURE_REPLAYED, whether the replay is an attacker or your own retry loop. - Retries. Because of the rule above, a retry is a new signature: new timestamp, new nonce, same body. Sign again rather than resending.
Together these mean a captured request is useless five minutes later, and useless immediately if it has already been delivered once.
A complete client
Everything above, in one function you can paste into your server. It fetches server time, seals the body when there is one, signs, and unwraps the response envelope:
Node.js
import crypto from 'node:crypto';
const BASE_URL = process.env.ECASH_BASE_URL; // …/api (no version segment)
const API_KEY = process.env.ECASH_API_KEY;
const API_SECRET = process.env.ECASH_API_SECRET; // signing secret
const ENC_KEY = Buffer.from(process.env.ECASH_ENC_KEY, 'hex'); // 32 bytes
/** Seal a plaintext object: base64(iv ‖ tag ‖ ciphertext). */
function seal(body) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENC_KEY, iv);
const ciphertext = Buffer.concat([
cipher.update(JSON.stringify(body), 'utf8'),
cipher.final(),
]);
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64');
}
/** Server time, so a skewed clock cannot invalidate every signature. */
async function serverUnixTime() {
const res = await fetch(`${BASE_URL}/v1/payments/server-time`);
const { data } = await res.json();
return String(data.unix);
}
export async function callEcashPay(method, path, body) {
const rawBody = body === undefined ? '' : seal(body);
const timestamp = await serverUnixTime();
const nonce = crypto.randomUUID();
const signature = crypto
.createHmac('sha256', API_SECRET)
.update(`${timestamp}.${nonce}.${rawBody}`)
.digest('hex');
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
'X-Api-Key': API_KEY,
'X-Ecash-Timestamp': timestamp,
'X-Ecash-Nonce': nonce,
'X-Ecash-Signature': `t=${timestamp},v1=${signature}`,
...(body === undefined ? {} : { 'Content-Type': 'application/octet-stream' }),
},
...(body === undefined ? {} : { body: rawBody }),
});
const envelope = await res.json();
if (!res.ok) throw new Error(`${envelope.error.code}: ${envelope.error.message}`);
return envelope.data;
}
// Charge ₱1,500 and read it back.
const payment = await callEcashPay('POST', '/v1/payments', {
referenceId: 'order-1001',
method: 'EWALLET',
amount: 1500,
currency: 'PHP',
customerEmail: 'payer@example.com',
});
const fresh = await callEcashPay('GET', `/v1/payments/${payment.id}`);Python
Needs requests and cryptography.
import os, json, base64, hmac, hashlib, secrets, uuid, requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
BASE_URL = os.environ["ECASH_BASE_URL"] # …/api (no version segment)
API_KEY = os.environ["ECASH_API_KEY"]
API_SECRET = os.environ["ECASH_API_SECRET"].encode()
ENC_KEY = bytes.fromhex(os.environ["ECASH_ENC_KEY"]) # 32 bytes
def seal(body: dict) -> str:
"""base64(iv ‖ tag ‖ ciphertext) — AESGCM.encrypt returns ciphertext‖tag."""
iv = secrets.token_bytes(12)
sealed = AESGCM(ENC_KEY).encrypt(iv, json.dumps(body).encode(), None)
ciphertext, tag = sealed[:-16], sealed[-16:]
return base64.b64encode(iv + tag + ciphertext).decode()
def server_unix_time() -> str:
res = requests.get(f"{BASE_URL}/v1/payments/server-time", timeout=10)
return str(res.json()["data"]["unix"])
def call_ecashpay(method: str, path: str, body: dict | None = None):
raw_body = "" if body is None else seal(body)
timestamp = server_unix_time()
nonce = uuid.uuid4().hex
signature = hmac.new(
API_SECRET, f"{timestamp}.{nonce}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
headers = {
"X-Api-Key": API_KEY,
"X-Ecash-Timestamp": timestamp,
"X-Ecash-Nonce": nonce,
"X-Ecash-Signature": f"t={timestamp},v1={signature}",
}
if body is not None:
headers["Content-Type"] = "application/octet-stream"
res = requests.request(
method, f"{BASE_URL}{path}", headers=headers,
data=raw_body if body is not None else None, timeout=30,
)
envelope = res.json()
if not res.ok:
raise RuntimeError(f"{envelope['error']['code']}: {envelope['error']['message']}")
return envelope["data"]
payment = call_ecashpay("POST", "/v1/payments", {
"referenceId": "order-1001",
"method": "EWALLET",
"amount": 1500,
"currency": "PHP",
"customerEmail": "payer@example.com",
})Both fetch server time on every call, which is one extra round trip. In production, read it once and keep the offset between our clock and yours — the tolerance is five minutes, so a single measured offset stays valid for a very long time.
The API reference carries the same recipe per endpoint in six languages, with that endpoint's own path and body already filled in.

