# webhooks.cc agent registration (auth.md)

This service implements the [auth.md](https://auth.md) agent-registration
protocol so AI agents can obtain an API credential for [webhooks.cc](https://webhooks.cc)
programmatically. Three registration flows are supported, plus an in-app claim
ceremony and provider-initiated revocation.

## Discovery

- Protected Resource Metadata: `https://webhooks.cc/.well-known/oauth-protected-resource`
- Authorization Server Metadata: `https://webhooks.cc/.well-known/oauth-authorization-server`
- Resource (audience): `https://webhooks.cc/api/`

The credential is a webhooks.cc API key (prefix `whcc_`). Send it as
`Authorization: Bearer whcc_...` to the API at `https://webhooks.cc/api/`. Scopes are
`webhooks:read` and `webhooks:write` (advisory in v1 — recorded on the key but
not enforced at the bearer boundary).

## Register

`POST https://webhooks.cc/api/agent/auth`

The request body selects the flow via `type` (the alias `identity_type` is
also accepted).

### 1. Anonymous

Returns an unowned API key immediately. The key works right away, but is not yet
bound to a user: account-scoped operations (creating endpoints, listing
requests) require a claimed key and return `403` until the in-app claim
ceremony below completes.

```json
{ "type": "anonymous", "requested_credential_type": "api_key", "client_name": "my-agent" }
```

Response:

```json
{
  "registration_id": "<uuid>",
  "registration_type": "anonymous",
  "credential_type": "api_key",
  "credential": "whcc_...",
  "credential_expires": null,
  "scopes": ["webhooks:read", "webhooks:write"],
  "claim_url": "https://webhooks.cc/agent/claim",
  "claim_token": "clm_...",
  "user_code": "ABCD-EFGH",
  "claim_token_expires": "<iso8601>",
  "post_claim_scopes": ["webhooks:read", "webhooks:write"]
}
```

The credential works immediately in a bounded sandbox before it is claimed: an
unclaimed key may create ephemeral endpoints and read only its own captured
requests at `https://webhooks.cc/api/agent/sandbox/endpoints`. See "Sandbox" below.

### 2. Verified email (OTP)

A 6-digit one-time code is emailed to the address. The credential is WITHHELD
until the code is confirmed.

```json
{ "type": "verified_email", "email": "dev@example.com", "client_name": "my-agent" }
```

Response (no credential yet):

```json
{
  "registration_id": "<uuid>",
  "registration_type": "email-verification",
  "claim_url": "https://webhooks.cc/agent/claim",
  "claim_token": "clm_...",
  "claim_token_expires": "<iso8601>",
  "post_claim_scopes": ["webhooks:read", "webhooks:write"]
}
```

Then confirm the OTP at the verify endpoint to receive the credential:

`POST https://webhooks.cc/api/agent/auth/claim/verify-otp`

```json
{ "claim_token": "clm_...", "otp": "123456" }
```

Response on success:

```json
{
  "credential_type": "api_key",
  "credential": "whcc_...",
  "scopes": ["webhooks:read", "webhooks:write"]
}
```

The OTP expires in 10 minutes and allows up to 5 attempts.

### 3. Identity assertion (ID-JAG)

This is the `identity_assertion` identity type. Present a verified identity
assertion (`urn:ietf:params:oauth:token-type:id-jag`) from a trusted provider.
Verified synchronously against the provider's JWKS — no claim ceremony. The
credential is returned immediately, bound to the user matched by (or provisioned
from) the verified email.

`POST https://webhooks.cc/api/agent/auth` with `Content-Type: application/jwt`; the body is the
ID-JAG. The assertion MUST:

- have header `typ: "oauth-id-jag+jwt"` and `alg` in the provider's allow-list,
- come from a trusted issuer,
- target audience `https://webhooks.cc/api/`,
- be unexpired (with a jti for replay protection),
- carry a verified email (`email_verified: true`) — REQUIRED; the credential is
  bound to the account matched by (or provisioned from) that verified email, so a
  phone-only assertion is not sufficient.

Response on success:

```json
{
  "credential_type": "api_key",
  "credential": "whcc_...",
  "credential_expires": null,
  "scopes": ["webhooks:read", "webhooks:write"]
}
```

## Claim ceremony (anonymous flow)

A human binds the anonymous key to their webhooks.cc account in-app (NOT via an
email link). This mirrors the CLI device-auth verify page. There are two
equivalent ways for the human to identify the registration; both end at the
same confirm step:

1. **Claim link** — open `https://webhooks.cc/agent/claim?token=<claim_token>` while logged in and
   confirm. Hand the human the full `claim_url` with the `claim_token`.
2. **Claim code** — open `https://webhooks.cc/agent/claim` while logged in, type the short
   `user_code` (e.g. `ABCD-EFGH`) shown by the agent, and confirm. The code is
   read off the agent's output — no copy/paste of a long token needed.

On confirmation the anonymous key is rebound to that account and granted
`post_claim_scopes`.

The confirm step is session-authenticated and accepts EITHER input:

`POST https://webhooks.cc/api/agent/auth/claim/confirm` (browser session token) with
`{ "claim_token": "clm_..." }` OR `{ "user_code": "ABCD-EFGH" }`.

Agents discover the outcome by polling the register/poll endpoint with the
`claim_token`; the status transitions `pending` -> `claimed`. The poll response
echoes `user_code` while pending so the agent can re-surface the code to the
human.

## Sandbox (unclaimed credentials)

To make the anonymous credential useful immediately — before it is claimed — an
unclaimed agent key has a narrow, bounded capability surface at
`https://webhooks.cc/api/agent/sandbox/endpoints`:

- `POST` — create an EPHEMERAL endpoint (capture-only; bounded expiry,
  capacity-capped per key). Returns the endpoint `url`, `slug`, and `expiresAt`.
- `GET` — list this key's sandbox endpoints, or `GET ?slug=<slug>` to read ONLY
  the requests captured by one of them.

Cross-tenant isolation is strict: an unclaimed key can only ever see endpoints
and requests created under its own credential — never another agent's and never
a real user's. Once the key is claimed it leaves the sandbox and uses the full
`https://webhooks.cc/api/` API. The sandbox does not configure mock responses, notification
URLs, or signing — claim the credential for those.

## Error catalog

All errors return a JSON body `{ "error": "<code>" }` with an appropriate HTTP
status:

| Code | Meaning |
| --- | --- |
| `invalid_request` | Malformed body or missing required field |
| `invalid_issuer` | ID-JAG issuer is not in the trusted set |
| `invalid_audience` | ID-JAG `aud` is not `https://webhooks.cc/api/` |
| `invalid_signature` | ID-JAG signature/claims failed verification |
| `credential_expired` | ID-JAG is expired |
| `replay_detected` | ID-JAG/logout `jti` was already used |
| `missing_verified_email` | The assertion is missing a verified email (a verified email is required) |
| `invalid_claim_token` | Unknown or malformed claim token |
| `claim_expired` | Claim/OTP window elapsed |
| `previously_claimed` | Claim/OTP already completed |
| `otp_invalid` | Wrong OTP (attempt counted) |
| `otp_expired` | OTP locked (too many attempts) or expired |
| `too_many_keys` | The target account hit its API-key limit |
| `rate_limited` | Too many registration/OTP attempts from this client or email |
| `temporarily_unavailable` | Too many pending anonymous registrations; retry later |
| `unsupported_credential_type` | Requested credential type is not offered (use `api_key`) |
| `invalid_email` | The supplied email is missing or malformed |
| `invalid_token` | An ID-JAG/logout token is malformed or missing required claims |

## For identity providers (ID-JAG onboarding)

To have your assertions accepted by the `identity_assertion` flow, you must be
added to webhooks.cc's trusted-provider set. This set is DEPLOYMENT CONFIG (the
`AGENT_IDJAG_PROVIDERS` env var, a JSON array) — it is never mutable at runtime,
so a provider cannot self-register. Each entry pins an issuer and how to resolve
its JWKS:

```jsonc
[
  {
    // Issuer identifier — MUST be an absolute https URL equal to the JWT "iss".
    "iss": "https://idp.example.com",
    // Optional explicit JWKS endpoint. Defaults to
    // "${iss}/.well-known/jwks.json" when omitted.
    "jwks_uri": "https://idp.example.com/.well-known/jwks.json",
    // Permitted signing algorithms for this provider (default: ES256, RS256).
    "algs": ["ES256", "RS256"]
  }
]
```

JWKS is resolved with caching and a single refetch on a key-id (`kid`) miss, so
key rotation is picked up without a redeploy as long as the new key is published
at the same `jwks_uri`. Inline static keys are supported for testing via a
`jwks` field (a JWK Set object) in place of `jwks_uri`.

Your assertion MUST: use header `typ: "oauth-id-jag+jwt"` with an `alg` in the
allow-list, set `iss` to a trusted issuer, target audience `https://webhooks.cc/api/`, carry
a unique `jti` (single-use; replays are rejected forever), be unexpired, and
include a verified email (`email_verified: true`). A verified email is REQUIRED:
the credential is bound to the account matched by (or provisioned from) that
email, so a phone-only assertion is not sufficient. An unverified email never
resolves or provisions an account.

## Revocation (providers only)

Trusted identity providers revoke previously issued credentials by POSTing a
`logout+jwt` to:

`POST https://webhooks.cc/api/agent/auth/revoke` with `Content-Type: application/logout+jwt`; the body is
the logout token. It is verified via the same trust path (issuer JWKS, `jti`
replay with purpose `logout`, `typ: "logout+jwt"`). On success all credentials
issued for that issuer+subject are revoked. Agents never call this endpoint.
