# API key exchange

The API key exchange is the one endpoint you must host for ZeroClick. When a
buyer purchases a plan, ZeroClick calls it to trade the buyer's identity for
an API key in your system. When a plan ends, ZeroClick calls it again to
revoke that key. Only ZeroClick can call this endpoint: every request is
signed, and you must reject requests that fail verification.

## Provision a key

`POST https://{your-api}/zeroclick/api-key`

Called when a buyer purchases a plan, or extends a plan after a lapse.

**Request body**

```json
{
	"userId": "user_8f3kz1",
	"plan": "pln_starter_monthly"
}
```

| Field | Description |
| --- | --- |
| `userId` | A stable, opaque identifier for the buyer. The same buyer always has the same `userId` for your service. |
| `plan` | The ID of the plan that was purchased, as shown in the dashboard. Use it to scope the key's entitlements. |

**Response** `200 OK`

```json
{
	"apiKey": "sk_live_b64x..."
}
```

The key should be valid immediately: ZeroClick injects it into the buyer's
very next proxied request.

## Revoke a key

`DELETE https://{your-api}/zeroclick/api-key`

Called when a plan expires, is cancelled, or a renewal payment fails. The
request body is the same shape (`userId` and `plan`). Invalidate the key you
issued for that buyer and return `200 OK`. After revocation, ZeroClick stops
proxying requests for that buyer until they purchase again.

> **Revocation is your safety net**
>
> ZeroClick stops injecting a revoked buyer's key at the proxy, but the key
> itself lives in your system. Treat the DELETE call as authoritative: a key
> that isn't revoked keeps working for anyone who holds it.

## Authenticating ZeroClick

Every request to your exchange endpoint carries two headers:

| Header | Value |
| --- | --- |
| `X-ZeroClick-Timestamp` | Unix timestamp (seconds) when the request was signed. |
| `X-ZeroClick-Signature` | `sha256=` followed by the hex HMAC-SHA256 of `"{timestamp}.{rawBody}"`, keyed with your service's signing secret. |

Your signing secret is shown in the dashboard under your service's settings.
To verify a request:

1. Read the raw request body, before any JSON parsing or middleware touches
   it.
2. Compute `HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}")` and hex-encode
   it.
3. Compare against the signature header using a constant-time comparison.
4. Reject requests whose timestamp is more than five minutes old to prevent
   replay.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyZeroClickRequest(
	signingSecret: string,
	rawBody: string,
	timestampHeader: string,
	signatureHeader: string,
): boolean {
	const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestampHeader));
	if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;

	const expected = `sha256=${createHmac("sha256", signingSecret)
		.update(`${timestampHeader}.${rawBody}`)
		.digest("hex")}`;

	const a = Buffer.from(signatureHeader);
	const b = Buffer.from(expected);
	return a.length === b.length && timingSafeEqual(a, b);
}
```

> **Never accept unsigned requests**
>
> This endpoint mints credentials. If signature verification fails or the
> headers are missing, return 401 and do nothing else.

## Retries and idempotency

ZeroClick treats any `2xx` response as success and retries other responses
with exponential backoff. Make both operations idempotent:

- A repeated `POST` for the same `userId` and `plan` should return the same
  key, or a fresh key that replaces the old one. It should not create a
  duplicate buyer.
- A repeated `DELETE` for an already-revoked key should still return `200 OK`.

If your endpoint is down during a purchase, ZeroClick keeps retrying and
completes provisioning when it recovers; the buyer's proxied requests are held
off until a key exists.
