When ZAM activates your listing, it signs every request with HMAC-SHA256 so your service can verify authenticity. This prevents anyone from calling your endpoint directly and bypassing ZAM.
How it works
- You generate a signing secret for your provider (starts with
whsec_).
- When a buyer activates your listing, ZAM builds a payload envelope with metadata (timestamp, request hash, buyer info) and signs it with your secret.
- Your service receives two headers:
X-ZAM-Payload (the envelope) and X-ZAM-Signature (the HMAC).
- Your service verifies the signature matches before processing the request.
Set up a signing secret
# Generate a signing secret for your provider
zam listings get-secret PROVIDER_ID
# If no secret exists yet, ZAM creates one automatically
# The secret starts with whsec_ and is shown only once
To rotate a compromised secret:zam listings rotate-secret PROVIDER_ID
Signing secrets are managed via zam listings (not zam providers) because secrets are tied to the listing that buyers interact with.
# Create a signing secret
curl -X POST https://api.zeroclick.am/v1/providers/PROVIDER_ID/signing-secret \
-H "x-zam-api-key: zam_your_key_here"
# Response: { secret: "whsec_..." }
# Rotate the secret
curl -X POST https://api.zeroclick.am/v1/providers/PROVIDER_ID/signing-secret/rotate \
-H "x-zam-api-key: zam_your_key_here"
# Response: { secret: "whsec_..." }
The signing secret is shown only once when created or rotated. Store it securely (e.g., as an environment variable or in your platform’s secrets manager).
Verify with zam-verify
The easiest way to verify requests is with the zam-verify package. It handles signature verification, timestamp freshness, and body hash checking in one call.
Node.js / Cloudflare Workers
import { verifyZamRequest } from "zam-verify";
export default {
async fetch(request: Request): Promise<Response> {
if (new URL(request.url).pathname === "/run" && request.method === "POST") {
const signature = request.headers.get("X-ZAM-Signature");
const payload = request.headers.get("X-ZAM-Payload");
if (!signature || !payload) {
return Response.json({ error: "Missing ZAM headers" }, { status: 401 });
}
const body = await request.text();
const result = verifyZamRequest({
signature,
payload,
body,
signingSecret: process.env.ZAM_SIGNING_SECRET,
});
if (!result.valid) {
return Response.json({ error: result.error }, { status: 401 });
}
// result.claims contains verified metadata (buyerId, payment, etc.)
const input = JSON.parse(body);
const output = await handleRequest(input);
return Response.json(output);
}
// ... other endpoints
},
};
Hono middleware
If your service uses Hono, zam-verify provides a middleware:
import { Hono } from "hono";
import { zamAuth } from "zam-verify/hono";
const app = new Hono();
// Protect the /run endpoint
app.use("/run", zamAuth({ secret: process.env.ZAM_SIGNING_SECRET }));
app.post("/run", (c) => {
const claims = c.get("zam"); // verified ZamPayload
const body = JSON.parse(c.get("rawBody")); // parsed request body
return c.json(handleRequest(body));
});
Every request from ZAM to your service includes these headers:
| Header | Format | Contents |
|---|
X-ZAM-Payload | Base64 string | JSON envelope with request metadata |
X-ZAM-Signature | sha256=<hex> | HMAC-SHA256 of the payload using your signing secret |
Payload envelope
The decoded X-ZAM-Payload contains:
{
"v": 1,
"ts": 1710936000,
"rid": "order-uuid",
"bodyHash": "sha256:abc123...",
"platform": "zam",
"listingId": "ZL-...",
"buyerId": "buyer-uuid",
"payment": {
"status": "paid",
"amountMillicents": 100000,
"currency": "USD",
"method": "balance"
}
}
| Field | Description |
|---|
v | Envelope version (currently 1) |
ts | Unix timestamp of the request |
rid | Order ID (unique per activation) |
bodyHash | sha256: + hex SHA-256 hash of the raw request body |
platform | Always "zam" |
listingId | Your listing’s ZID |
buyerId | The buyer’s wallet ID |
payment | Payment details for this activation |
What zam-verify checks
The verifyZamRequest function validates three things:
- Signature — HMAC-SHA256 of the base64 payload using your signing secret (constant-time comparison)
- Timestamp — rejects requests older than 5 minutes (configurable via
maxAgeSeconds)
- Body hash — SHA-256 of the request body must match the hash in the payload envelope
Manual verification
If you can’t use zam-verify (e.g., non-Node.js runtimes), here’s the verification logic:
Cloudflare Workers (Web Crypto API)
const SIGNING_SECRET = "whsec_your_secret_here"; // Use env var in production
async function verifyZamSignature(request: Request): Promise<boolean> {
const payload = request.headers.get("X-ZAM-Payload");
const signature = request.headers.get("X-ZAM-Signature");
if (!payload || !signature) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(SIGNING_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
const expected = "sha256=" + [...new Uint8Array(mac)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// Constant-time comparison
if (signature.length !== expected.length) return false;
const a = encoder.encode(signature);
const b = encoder.encode(expected);
let mismatch = 0;
for (let i = 0; i < a.length; i++) {
mismatch |= a[i] ^ b[i];
}
return mismatch === 0;
}
Node.js (crypto module)
import crypto from "node:crypto";
function verifyZamSignature(payload: string, signature: string, secret: string): boolean {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(payload).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Without a signing secret
If you don’t set up a signing secret, ZAM still calls your endpoint — but without signature headers. Your service has no way to verify the request came from ZAM. We strongly recommend setting up a signing secret for any production service.