Examples
Complete, dependency-free clients. Each one signs requests (HMAC or Bearer), unwraps the envelope, raises on errors and contains the webhook verifier. Copy the file into your project and set three environment variables:
Shell
export ENERGY_API_BASE=https://energy.payany.com
export ENERGY_ACCESS_ID=ek_…
export ENERGY_SECRET=…
export ENERGY_AUTH_MODE=hmac # or bearer — must match the keyThen try it in test mode (nothing is charged):
Shell
python client.py account
python client.py price 65000 1h
python client.py order TXYZ… 65000 1h --external-id inv-1 --test
python client.py echoUnit-test your own signer with the test vectors.
Python
Python 3.10+, standard library only. verify_webhook checks an incoming webhook.
Python
#!/usr/bin/env python3
"""PayAny Energy reseller API — minimal Python client (standard library only).
Signing (HMAC keys):
string_to_sign = ts + "\\n" + nonce + "\\n" + METHOD + "\\n" + path_with_query + "\\n" + raw_body
X-Signature = hex(HMAC_SHA256(secret, string_to_sign))
Usage:
export ENERGY_API_BASE=http://127.0.0.1:8030
export ENERGY_ACCESS_ID=ek_... ENERGY_SECRET=... ENERGY_AUTH_MODE=hmac # or bearer
python client.py account
python client.py terms
python client.py price 65000 1h
python client.py order T... 65000 1h --external-id inv-1 --test
python client.py get EN-XXXXXXXXXX
python client.py echo
``verify_webhook`` checks an incoming webhook (see "Verify a webhook" in API.md).
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import json
import os
import secrets
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Mapping
from typing import Any
SIGNATURE_WINDOW_SECONDS = 300
def sign(
secret: str, timestamp: str, nonce: str, method: str, path_with_query: str, body: bytes
) -> str:
"""Hex HMAC-SHA256 of the canonical string (body = the exact bytes sent)."""
message = f"{timestamp}\n{nonce}\n{method.upper()}\n{path_with_query}\n".encode() + body
return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
def verify_webhook(
secret: str,
headers: Mapping[str, str],
body: bytes,
path_with_query: str,
*,
expected_access_id: str | None = None,
tolerance: int = SIGNATURE_WINDOW_SECONDS,
now: float | None = None,
) -> bool:
"""True when a webhook is authentic and fresh.
``path_with_query`` is the path (+ ``?query``) of YOUR callback URL, e.g.
``/hooks/energy``; ``body`` the raw request bytes (verify BEFORE parsing).
Deduplicate on ``X-Delivery-Id`` afterwards: retries reuse it.
"""
h = {k.lower(): v for k, v in headers.items()}
timestamp = h.get("x-timestamp", "")
nonce = h.get("x-nonce", "")
given = h.get("x-signature", "")
if expected_access_id is not None and h.get("x-access-id") != expected_access_id:
return False
if not timestamp.isdigit() or abs((now or time.time()) - int(timestamp)) > tolerance:
return False
expected = sign(secret, timestamp, nonce, "POST", path_with_query, body)
return hmac.compare_digest(expected, given.strip().lower())
class ApiError(Exception):
def __init__(self, status: int, code: int, message: str, data: Any = None) -> None:
super().__init__(f"HTTP {status} code={code}: {message}")
self.status, self.code, self.message, self.data = status, code, message, data
class EnergyClient:
def __init__(
self,
base_url: str,
access_id: str,
secret: str,
*,
mode: str = "hmac",
timeout: float = 15.0,
) -> None:
self.base_url = base_url.rstrip("/")
self.access_id = access_id
self.secret = secret
self.mode = mode.lower()
self.timeout = timeout
def _auth_headers(self, method: str, path_with_query: str, body: bytes) -> dict[str, str]:
if self.mode == "bearer":
return {"Authorization": f"Bearer {self.access_id}.{self.secret}"}
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16)
return {
"X-Access-Id": self.access_id,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": sign(self.secret, timestamp, nonce, method, path_with_query, body),
}
def request(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
json_body: Any = None,
headers: Mapping[str, str] | None = None,
) -> Any:
"""Call ``path`` (e.g. ``/v1/account``); returns ``data`` or raises ApiError."""
query = urllib.parse.urlencode({k: v for k, v in (params or {}).items() if v is not None})
path_with_query = f"{path}?{query}" if query else path
body = b"" if json_body is None else json.dumps(json_body, separators=(",", ":")).encode()
all_headers = {
"Accept": "application/json",
**self._auth_headers(method, path_with_query, body),
}
if json_body is not None:
all_headers["Content-Type"] = "application/json"
all_headers.update(headers or {})
if not self.base_url.startswith(("http://", "https://")):
raise ValueError("base_url must be http(s)")
req = urllib.request.Request( # noqa: S310 — scheme checked above
self.base_url + path_with_query,
data=body if method.upper() != "GET" else None,
method=method.upper(),
headers=all_headers,
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
status, raw, ctype = resp.status, resp.read(), resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as err:
status, raw, ctype = err.code, err.read(), err.headers.get("Content-Type", "")
if "text/csv" in ctype:
return raw.decode()
payload = json.loads(raw or b"{}")
if status >= 400 or payload.get("code", 0) != 0:
raise ApiError(
status, payload.get("code", 0), payload.get("message", ""), payload.get("data")
)
return payload.get("data")
# ── convenience ──
def terms(self) -> Any:
return self.request("GET", "/v1/terms")
def price(self, energy: int, term: str) -> Any:
return self.request("GET", "/v1/price", params={"energy": energy, "term": term})
def account(self) -> Any:
return self.request("GET", "/v1/account")
def create_order(self, *, test_mode: bool = False, **order: Any) -> Any:
headers = {"X-Test-Mode": "1"} if test_mode else None
return self.request("POST", "/v1/orders", json_body=order, headers=headers)
def get_order(self, order_no: str) -> Any:
return self.request("GET", f"/v1/orders/{order_no}")
def echo(self, payload: Any = None) -> Any:
return self.request("POST", "/v1/auth/echo", json_body=payload or {"hello": "world"})
def _client_from_env() -> EnergyClient:
try:
return EnergyClient(
os.environ.get("ENERGY_API_BASE", "http://127.0.0.1:8030"),
os.environ["ENERGY_ACCESS_ID"],
os.environ["ENERGY_SECRET"],
mode=os.environ.get("ENERGY_AUTH_MODE", "hmac"),
)
except KeyError as missing:
sys.exit(f"set {missing.args[0]} (and ENERGY_SECRET / ENERGY_AUTH_MODE)")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="PayAny Energy reseller API client")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("account")
sub.add_parser("terms")
sub.add_parser("echo")
p_price = sub.add_parser("price")
p_price.add_argument("energy", type=int)
p_price.add_argument("term")
p_order = sub.add_parser("order")
p_order.add_argument("energy_address")
p_order.add_argument("energy", type=int)
p_order.add_argument("term")
p_order.add_argument("--external-id", default=None)
p_order.add_argument("--charge-asset", default="TRX")
p_order.add_argument("--max-price-trx", default=None)
p_order.add_argument("--test", action="store_true", help="X-Test-Mode: 1 (nothing charged)")
p_get = sub.add_parser("get")
p_get.add_argument("order_no")
args = parser.parse_args(argv)
client = _client_from_env()
try:
if args.cmd == "account":
out = client.account()
elif args.cmd == "terms":
out = client.terms()
elif args.cmd == "echo":
out = client.echo()
elif args.cmd == "price":
out = client.price(args.energy, args.term)
elif args.cmd == "order":
order = {
"external_id": args.external_id or f"cli-{secrets.token_hex(6)}",
"energy_address": args.energy_address,
"energy": args.energy,
"term": args.term,
"charge_asset": args.charge_asset,
}
if args.max_price_trx:
order["max_price_trx"] = args.max_price_trx
out = client.create_order(test_mode=args.test, **order)
else:
out = client.get_order(args.order_no)
except ApiError as err:
print(json.dumps({"error": str(err), "data": err.data}, indent=2), file=sys.stderr)
return 1
print(json.dumps(out, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())Node.js
Node 18+, no dependencies. verifyWebhook checks an incoming webhook (pass the raw body
Buffer).
Node.js
// PayAny Energy reseller API — minimal Node.js client (Node 18+, no dependencies).
//
// string_to_sign = ts + "\n" + nonce + "\n" + METHOD + "\n" + path_with_query + "\n" + raw_body
// X-Signature = hex(HMAC_SHA256(secret, string_to_sign))
//
// Usage:
// ENERGY_API_BASE=http://127.0.0.1:8030 ENERGY_ACCESS_ID=ek_... ENERGY_SECRET=... \
// ENERGY_AUTH_MODE=hmac node client.js account
// node client.js price 65000 1h
// node client.js order TXYZ... 65000 1h --test
'use strict';
const crypto = require('node:crypto');
function sign(secret, timestamp, nonce, method, pathWithQuery, body) {
const head = Buffer.from(`${timestamp}\n${nonce}\n${method.toUpperCase()}\n${pathWithQuery}\n`, 'utf8');
const raw = Buffer.isBuffer(body) ? body : Buffer.from(body || '', 'utf8');
return crypto.createHmac('sha256', secret).update(Buffer.concat([head, raw])).digest('hex');
}
// Verify an incoming webhook. `headers` = request headers (any case), `rawBody` = Buffer of the
// exact bytes received, `pathWithQuery` = the path (+ query) of YOUR callback URL.
function verifyWebhook(secret, headers, rawBody, pathWithQuery, { expectedAccessId, tolerance = 300, now } = {}) {
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)]));
const ts = h['x-timestamp'] || '';
if (expectedAccessId !== undefined && h['x-access-id'] !== expectedAccessId) return false;
if (!/^\d+$/.test(ts)) return false;
const current = now !== undefined ? now : Date.now() / 1000;
if (Math.abs(current - Number(ts)) > tolerance) return false;
const expected = Buffer.from(sign(secret, ts, h['x-nonce'] || '', 'POST', pathWithQuery, rawBody), 'utf8');
const given = Buffer.from((h['x-signature'] || '').trim().toLowerCase(), 'utf8');
return expected.length === given.length && crypto.timingSafeEqual(expected, given);
}
class ApiError extends Error {
constructor(status, code, message, data) {
super(`HTTP ${status} code=${code}: ${message}`);
Object.assign(this, { status, code, data });
}
}
class EnergyClient {
constructor(baseUrl, accessId, secret, { mode = 'hmac' } = {}) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.accessId = accessId;
this.secret = secret;
this.mode = mode.toLowerCase();
}
authHeaders(method, pathWithQuery, body) {
if (this.mode === 'bearer') return { Authorization: `Bearer ${this.accessId}.${this.secret}` };
const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(16).toString('hex');
return {
'X-Access-Id': this.accessId,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': sign(this.secret, ts, nonce, method, pathWithQuery, body),
};
}
async request(method, path, { params, json, headers } = {}) {
const query = new URLSearchParams(Object.entries(params || {}).filter(([, v]) => v !== undefined && v !== null)).toString();
const pathWithQuery = query ? `${path}?${query}` : path;
const body = json === undefined ? '' : JSON.stringify(json); // sign exactly what is sent
const res = await fetch(this.baseUrl + pathWithQuery, {
method,
headers: {
Accept: 'application/json',
...(json === undefined ? {} : { 'Content-Type': 'application/json' }),
...this.authHeaders(method, pathWithQuery, body),
...(headers || {}),
},
body: method === 'GET' ? undefined : body,
});
if ((res.headers.get('content-type') || '').includes('text/csv')) return res.text();
const payload = await res.json();
if (res.status >= 400 || payload.code !== 0) throw new ApiError(res.status, payload.code, payload.message, payload.data);
return payload.data;
}
terms() { return this.request('GET', '/v1/terms'); }
price(energy, term) { return this.request('GET', '/v1/price', { params: { energy, term } }); }
account() { return this.request('GET', '/v1/account'); }
createOrder(order, { testMode = false } = {}) {
return this.request('POST', '/v1/orders', { json: order, headers: testMode ? { 'X-Test-Mode': '1' } : {} });
}
getOrder(orderNo) { return this.request('GET', `/v1/orders/${encodeURIComponent(orderNo)}`); }
echo() { return this.request('POST', '/v1/auth/echo', { json: { hello: 'world' } }); }
}
async function main(argv) {
const client = new EnergyClient(
process.env.ENERGY_API_BASE || 'http://127.0.0.1:8030',
process.env.ENERGY_ACCESS_ID,
process.env.ENERGY_SECRET,
{ mode: process.env.ENERGY_AUTH_MODE || 'hmac' },
);
const [cmd, ...args] = argv;
let out;
if (cmd === 'account') out = await client.account();
else if (cmd === 'terms') out = await client.terms();
else if (cmd === 'echo') out = await client.echo();
else if (cmd === 'price') out = await client.price(Number(args[0]), args[1]);
else if (cmd === 'order') {
out = await client.createOrder(
{ external_id: `js-${crypto.randomBytes(6).toString('hex')}`, energy_address: args[0], energy: Number(args[1]), term: args[2] },
{ testMode: args.includes('--test') },
);
} else if (cmd === 'get') out = await client.getOrder(args[0]);
else throw new Error('usage: client.js account|terms|echo|price E T|order ADDR E T [--test]|get ORDER_NO');
console.log(JSON.stringify(out, null, 2));
}
module.exports = { EnergyClient, ApiError, sign, verifyWebhook };
if (require.main === module) {
main(process.argv.slice(2)).catch((err) => {
console.error(err.message, err.data ? JSON.stringify(err.data) : '');
process.exit(1);
});
}PHP
PHP 8.1+ with ext-curl. energy_verify_webhook checks an incoming webhook.
PHP
<?php
// PayAny Energy reseller API — minimal PHP client (PHP 8.1+, ext-curl).
//
// string_to_sign = ts . "\n" . nonce . "\n" . METHOD . "\n" . path_with_query . "\n" . raw_body
// X-Signature = hash_hmac('sha256', string_to_sign, secret)
//
// Usage:
// ENERGY_API_BASE=http://127.0.0.1:8030 ENERGY_ACCESS_ID=ek_... ENERGY_SECRET=... \
// ENERGY_AUTH_MODE=hmac php client.php account
// php client.php price 65000 1h
// php client.php order TXYZ... 65000 1h --test
declare(strict_types=1);
function energy_sign(string $secret, string $ts, string $nonce, string $method, string $pathWithQuery, string $body): string
{
return hash_hmac('sha256', $ts . "\n" . $nonce . "\n" . strtoupper($method) . "\n" . $pathWithQuery . "\n" . $body, $secret);
}
/**
* Verify an incoming webhook.
* $headers: request headers (any case); $rawBody: file_get_contents('php://input');
* $pathWithQuery: the path (+ query) of YOUR callback URL, e.g. '/hooks/energy'.
*/
function energy_verify_webhook(string $secret, array $headers, string $rawBody, string $pathWithQuery, ?string $expectedAccessId = null, int $tolerance = 300): bool
{
$h = array_change_key_case($headers, CASE_LOWER);
$ts = (string)($h['x-timestamp'] ?? '');
if ($expectedAccessId !== null && ($h['x-access-id'] ?? '') !== $expectedAccessId) {
return false;
}
if (!ctype_digit($ts) || abs(time() - (int)$ts) > $tolerance) {
return false;
}
$expected = energy_sign($secret, $ts, (string)($h['x-nonce'] ?? ''), 'POST', $pathWithQuery, $rawBody);
return hash_equals($expected, strtolower(trim((string)($h['x-signature'] ?? ''))));
}
final class EnergyApiError extends RuntimeException
{
public function __construct(public int $status, public int $apiCode, string $message, public mixed $data = null)
{
parent::__construct("HTTP {$status} code={$apiCode}: {$message}");
}
}
final class EnergyClient
{
public function __construct(
private string $baseUrl,
private string $accessId,
private string $secret,
private string $mode = 'hmac',
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->mode = strtolower($mode);
}
private function authHeaders(string $method, string $pathWithQuery, string $body): array
{
if ($this->mode === 'bearer') {
return ["Authorization: Bearer {$this->accessId}.{$this->secret}"];
}
$ts = (string)time();
$nonce = bin2hex(random_bytes(16));
return [
"X-Access-Id: {$this->accessId}",
"X-Timestamp: {$ts}",
"X-Nonce: {$nonce}",
'X-Signature: ' . energy_sign($this->secret, $ts, $nonce, $method, $pathWithQuery, $body),
];
}
public function request(string $method, string $path, array $params = [], mixed $json = null, array $headers = []): mixed
{
$query = http_build_query(array_filter($params, fn ($v) => $v !== null), '', '&', PHP_QUERY_RFC3986);
$pathWithQuery = $query !== '' ? "{$path}?{$query}" : $path;
$body = $json === null ? '' : json_encode($json, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$all = array_merge(['Accept: application/json'], $json === null ? [] : ['Content-Type: application/json'],
$this->authHeaders($method, $pathWithQuery, $body), $headers);
$ch = curl_init($this->baseUrl . $pathWithQuery);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $all,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
if ($method !== 'GET') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$ctype = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($raw === false) {
throw new EnergyApiError(0, 0, 'network error');
}
if (str_contains($ctype, 'text/csv')) {
return $raw;
}
$payload = json_decode($raw, true);
if ($status >= 400 || ($payload['code'] ?? -1) !== 0) {
throw new EnergyApiError($status, (int)($payload['code'] ?? 0), (string)($payload['message'] ?? ''), $payload['data'] ?? null);
}
return $payload['data'];
}
public function terms(): mixed { return $this->request('GET', '/v1/terms'); }
public function price(int $energy, string $term): mixed { return $this->request('GET', '/v1/price', ['energy' => $energy, 'term' => $term]); }
public function account(): mixed { return $this->request('GET', '/v1/account'); }
public function createOrder(array $order, bool $testMode = false): mixed
{
return $this->request('POST', '/v1/orders', [], $order, $testMode ? ['X-Test-Mode: 1'] : []);
}
public function getOrder(string $orderNo): mixed { return $this->request('GET', '/v1/orders/' . rawurlencode($orderNo)); }
}
if (PHP_SAPI === 'cli' && realpath($argv[0]) === __FILE__) {
$client = new EnergyClient(
getenv('ENERGY_API_BASE') ?: 'http://127.0.0.1:8030',
(string)getenv('ENERGY_ACCESS_ID'),
(string)getenv('ENERGY_SECRET'),
getenv('ENERGY_AUTH_MODE') ?: 'hmac',
);
$cmd = $argv[1] ?? '';
try {
$out = match ($cmd) {
'account' => $client->account(),
'terms' => $client->terms(),
'price' => $client->price((int)$argv[2], $argv[3]),
'order' => $client->createOrder(
['external_id' => 'php-' . bin2hex(random_bytes(6)), 'energy_address' => $argv[2], 'energy' => (int)$argv[3], 'term' => $argv[4]],
in_array('--test', $argv, true),
),
'get' => $client->getOrder($argv[2]),
default => throw new InvalidArgumentException('usage: client.php account|terms|price E T|order ADDR E T [--test]|get ORDER_NO'),
};
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;
} catch (EnergyApiError $e) {
fwrite(STDERR, $e->getMessage() . PHP_EOL);
exit(1);
}
}