Skip to main content
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

  1. You generate a signing secret for your provider (starts with whsec_).
  2. When a buyer activates your listing, ZAM builds a payload envelope with metadata (timestamp, request hash, buyer info) and signs it with your secret.
  3. Your service receives two headers: X-ZAM-Payload (the envelope) and X-ZAM-Signature (the HMAC).
  4. 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.
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.
npm install zam-verify

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));
});

Request headers

Every request from ZAM to your service includes these headers:
HeaderFormatContents
X-ZAM-PayloadBase64 stringJSON envelope with request metadata
X-ZAM-Signaturesha256=<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"
  }
}
FieldDescription
vEnvelope version (currently 1)
tsUnix timestamp of the request
ridOrder ID (unique per activation)
bodyHashsha256: + hex SHA-256 hash of the raw request body
platformAlways "zam"
listingIdYour listing’s ZID
buyerIdThe buyer’s wallet ID
paymentPayment details for this activation

What zam-verify checks

The verifyZamRequest function validates three things:
  1. Signature — HMAC-SHA256 of the base64 payload using your signing secret (constant-time comparison)
  2. Timestamp — rejects requests older than 5 minutes (configurable via maxAgeSeconds)
  3. 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.