ECASHPAYECASHPAYDocumentation
Back to site

Introduction

  • EcashPay API
  • Quickstart

Core concepts

  • Environments and API keys
  • Signing requests
  • Errors

Accept payments

  • Creating a payment
  • Payment methods
  • Confirming the outcome
  • Refunds, capture and cancel

Testing

  • Testing

API reference

  • API reference

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:

Headers on every authenticated request
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>. The t must 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.

Rotation is a hard cutover

Rotating the pair invalidates every signature made with the old secret at the instant it happens — there is no overlap window. Every client you run has to be redeployed with the new values together.

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:

Sealing the body
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 ‖ rest

Two 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.

The pre-image
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.

Sign per request, never reuse

A timestamp, a nonce and a signature belong to exactly one request. Do not cache a signature, do not retry with the same nonce, and do not sign on a client you do not control — the signing secret is as powerful as the API key it accompanies.

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. Read GET /v1/payments/server-time instead of trusting the host clock — it returns unix, iso and 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

ecashpay.mjs
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.

ecashpay.py
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.

PreviousEnvironments and API keysNextErrors

On this page

  • The four headers
  • Where the key pair comes from
  • Step 1 — Seal the body
  • Step 2 — Sign what goes on the wire
  • Clocks, nonces and replays
  • A complete client