Request signing — reference for agents
Every POST /agentic_commerce/delegate_payment request to the EmoMeta gateway MUST be signed. This page is the authoritative spec — copy the algorithm exactly.
The algorithm, exactly
1. Build the request body as a JSON object (any key order is fine at this point).
2. Canonicalize the body:
- Recursively sort EVERY object's keys alphabetically (A-Z).
- Arrays keep their order.
- JSON.stringify with NO whitespace.
- The result is a string like: {"a":1,"b":{"c":2}}
3. Build the signing payload:
payload = Timestamp + "." + canonicalBody
where Timestamp is an RFC 3339 string (e.g. 2026-07-05T12:34:56.789Z)
4. Compute the signature:
signature = HMAC-SHA256(payload, signing_secret)
then encode as base64url:
standard base64, then replace "+" with "-", "/" with "_", strip trailing "="
5. Send two headers:
Signature: <base64url signature>
Timestamp: <the RFC 3339 timestamp from step 3>
The signing_secret comes from the faucet (POST /agent/onboarding).
The signature window is 300 seconds.Node.js
const crypto = require('crypto');
function sortKeys(v) {
if (Array.isArray(v)) return v.map(sortKeys);
if (v && typeof v === 'object')
return Object.keys(v).sort().reduce((a, k) => (a[k] = sortKeys(v[k]), a), {});
return v;
}
function signRequest(body, signingSecret) {
const canonical = JSON.stringify(sortKeys(body));
const timestamp = new Date().toISOString();
const payload = timestamp + '.' + canonical;
const signature = crypto.createHmac('sha256', signingSecret)
.update(payload).digest('base64url');
return { Signature: signature, Timestamp: timestamp };
}
// usage:
const { Signature, Timestamp } = signRequest(body, signingSecret);
fetch(url, { method: 'POST', headers: { ..., Signature, Timestamp }, body: JSON.stringify(body) });Python
import hmac, hashlib, base64, json
from datetime import datetime, timezone
def sort_keys(v):
if isinstance(v, dict): return {k: sort_keys(v[k]) for k in sorted(v)}
if isinstance(v, list): return [sort_keys(x) for x in v]
return v
def sign_request(body: dict, signing_secret: str):
canonical = json.dumps(sort_keys(body), separators=(',', ':'), ensure_ascii=False)
timestamp = datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
payload = timestamp + "." + canonical
raw = hmac.new(signing_secret.encode(), payload.encode(), hashlib.sha256).digest()
sig = base64.urlsafe_b64encode(raw).decode().rstrip('=')
return {"Signature": sig, "Timestamp": timestamp}
# usage:
headers.update(sign_request(body, signing_secret))
requests.post(url, json=body, headers=headers)bash + openssl + jq
SIGNING_SECRET="acp_demo_signing_secret" TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%S.%NZ) # RFC 3339 CANONICAL=$(echo "$BODY_JSON" | jq -cS .) # jq -S sorts keys, -c is compact SIG=$(printf '%s.%s' "$TIMESTAMP" "$CANONICAL" \ | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -binary \ | base64 | tr '+/' '-_' | tr -d '=') curl -X POST "$URL" \ -H "Signature: $SIG" -H "Timestamp: $TIMESTAMP" \ -H "content-type: application/json" \ --data "$BODY_JSON"
Common mistakes
- Not sorting nested keys. The sort is RECURSIVE.
{"b":{"y":1,"x":2}} must become {"b":{"x":2,"y":1}}
- Using standard base64 instead of base64url. Replace +/= with -_ and strip =.
- Signing the raw body string instead of the canonical one. Always canonicalize first.
- Forgetting the timestamp in the payload. It's timestamp + "." + body, not just body.
- Whitespace in the canonical JSON. Use JSON.stringify(x) with no indent arg.
- Timestamp outside the window. Must be within +/- 300s of server time.Source of truth: src/shared/signing.ts in the EmoMeta backend. This page mirrors it.