# Authenticating an AI agent with Zoral

Zoral is a small commerce API. **Most of what you want needs no authentication at
all** - the catalog, prices, stock and reviews are public. Read this only if you
need the authenticated `/agent` surface (e.g. to build carts under your own
client identity).

- Public, zero-auth API: https://getzoral.com/openapi.json
- MCP (also zero-auth): https://getzoral.com/mcp
- This document: https://getzoral.com/auth.md

## Discover

Everything is discoverable from one probe. Call any protected endpoint without a
token and read the `WWW-Authenticate` header:

```
curl -i https://api.getzoral.com/agent/products
```
```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.getzoral.com/.well-known/oauth-protected-resource"
```

That points at the RFC 9728 protected-resource metadata:

- Protected resource metadata (RFC 9728): https://api.getzoral.com/.well-known/oauth-protected-resource
- Authorization server metadata (RFC 8414): https://api.getzoral.com/.well-known/oauth-authorization-server

The authorization-server document carries an `agent_auth` block with the
machine-readable entry points:

```json
{
  "agent_auth": {
    "skill": "https://getzoral.com/auth.md",
    "register_uri": "https://api.getzoral.com/oauth/register",
    "revocation_uri": "https://api.getzoral.com/oauth/revoke",
    "identity_types_supported": ["anonymous"],
    "anonymous": { "credential_types_supported": ["client_credentials"] }
  }
}
```

## Pick a method

Zoral supports exactly one identity type: **`anonymous`**, via the OAuth 2.0
`client_credentials` grant.

Zoral does **not** support `identity_assertion` - there is no
`urn:ietf:params:oauth:token-type:id-jag` (ID-JAG) exchange and no
`verified_email` assertion, because Zoral has no agent-linked customer accounts
to assert against. Do not attempt an assertion exchange; it will be rejected.

There is no `claim_uri` for the same reason - see **Claim** below.

## Register

Open, self-service registration (RFC 7591). No human approval, no initial access
token, no "contact sales":

```
curl -X POST https://api.getzoral.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Acme Shopping Agent",
    "scope": "catalog:read cart:write",
    "grant_types": ["client_credentials"]
  }'
```

```json
{
  "client_id": "zac_...",
  "client_secret": "zoral_cs_...",
  "client_secret_expires_at": 0,
  "scope": "catalog:read cart:write",
  "token_endpoint": "https://api.getzoral.com/oauth/token"
}
```

**The `client_secret` is shown exactly once.** Zoral stores only a scrypt hash
and cannot recover it. Store it securely; if you lose it, register again.

### Scopes

| Scope | Grants |
| --- | --- |
| `catalog:read` | products, collections, categories, regions, reviews |
| `cart:write` | create and update carts and line items |

Request only what you need. The token endpoint intersects your request with the
scopes your client registered for.

## Claim

**Not applicable.** A claim ceremony exists to bind an agent registration to an
end-user account. Zoral has no agent-facing customer accounts, so there is
nothing to claim and no `claim_uri` is advertised.

Anything requiring a human - paying, order history - is handled by sending the
buyer to https://getzoral.com/store. See the `payment_provider` block on any ACP checkout
session.

## Use the credential

Exchange the client credentials for a bearer token:

```
curl -X POST https://api.getzoral.com/oauth/token \
  -u "zac_...:zoral_cs_..." \
  -d "grant_type=client_credentials" \
  -d "scope=catalog:read"
```

```json
{
  "access_token": "zoral_at_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "catalog:read"
}
```

Then call the API with it:

```
curl https://api.getzoral.com/agent/products \
  -H "Authorization: Bearer zoral_at_..."
```

Tokens live for **1 hour**. There are no refresh tokens - re-running the
`client_credentials` grant *is* the rotation (RFC 6749 §4.4.3).

## Errors

Every error is JSON. Never an HTML page.

| Status | Meaning | Do this |
| --- | --- | --- |
| `401` `invalid_client` | Bad client_id/client_secret at the token endpoint | Re-check credentials, or register again |
| `401` `invalid_token` | Token expired, revoked or unknown | Request a new token |
| `401` `unauthorized` | No `Authorization` header | Add `Authorization: Bearer ...` |
| `403` `insufficient_scope` | Token lacks the scope | Re-request the token with the scope named in the `WWW-Authenticate` header |
| `400` `invalid_scope` | Unknown or disallowed scope | Use only `catalog:read` / `cart:write` |
| `400` `unsupported_grant_type` | Not client_credentials | Use `grant_type=client_credentials` |
| `404` `unknown_endpoint` | Not an agent endpoint | See https://getzoral.com/openapi.json |
| `502`/`503` | Transient upstream | Retry with backoff |

`403` responses name the scope you need:

```
WWW-Authenticate: Bearer error="insufficient_scope", scope="cart:write", resource_metadata="https://api.getzoral.com/.well-known/oauth-protected-resource"
```

## Revocation

Revoke a token you no longer need (RFC 7009):

```
curl -X POST https://api.getzoral.com/oauth/revoke \
  -u "zac_...:zoral_cs_..." \
  -d "token=zoral_at_..."
```

Always returns `200`, including for tokens that never existed - so it cannot be
used to probe which tokens are valid. Revocation is immediate: tokens are opaque
and checked against the database on every request, so there is no denylist lag.

Only the client that issued a token may revoke it.

---
Canonical URL: https://getzoral.com/auth.md
Contact: hello@getzoral.com
