← home

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.