# Authentication for agents

How an AI agent obtains and uses a credential on Gomry. Gomry is an event
ticketing platform; agents read and manage events, attendees and contacts, and
can buy tickets on a user's behalf.

Which credential you need depends on whether you are acting **as a signed-in
user** (`agent_auth`) or **as a backend service** (`service_auth`). Buying
tickets is the `service_auth` case with one extra scope — it is open to any
agent holding a credential with `checkout: write`, not a separate partner
programme. Pick one in "Pick a method" below — they are not interchangeable.

**If you only want to buy a ticket for someone, you need no credential at
all.** Browse `GET https://www.gomry.com/api/v1/catalog/events`, create the checkout
session with `POST https://www.gomry.com/api/v1/checkout_sessions` and NO
`Authorization` header, and send the person its `continue_url` (see
Payment). They pay on that page; nobody signs in to Gomry or approves you.

**To pay yourself with a Shared Payment Token** you need a credential, and
still no human: register for the `client_credentials` grant in Step 3,
authenticate in Step 5, and buy in Step 6.

**To buy as a person, on their Gomry account** (their verified email, the
tickets in their account), have them sign in once with OAuth and ask for
`checkout:write` (Step 4).

## Step 1 — Discover

Start from the protected resource metadata. Every endpoint in this document is
advertised there, so an agent never needs to hardcode a URL:

- Protected resource: https://www.gomry.com/.well-known/oauth-protected-resource
- Authorization server: https://www.gomry.com/.well-known/oauth-authorization-server
- JWKS (token verification keys): https://www.gomry.com/.well-known/jwks.json
- Capability manifest: https://www.gomry.com/agents.md
- OpenAPI description: https://www.gomry.com/openapi.json
- Personal Agent Protocol (Poppy): https://www.gomry.com/.well-known/poppy.json. A personal
  agent authenticates with `private_key_jwt` from an HTTPS `client_id` (its
  metadata document), starts a session with the JWT bearer grant and a DPoP
  proof, and signs its user in with Direct Sign-In. It calls the REST API with
  `Authorization: DPoP <session token>` plus a `DPoP` proof: the catalog and
  checkout for anyone (with `poppy:write`, checkout buys on the person's own
  account), `/api/v1/me` for the person's tickets and orders, and, for an
  organizer, the organization endpoints (header `Gomry-Organization`), limited to
  the person's role and the scopes they granted.

An unauthenticated request to a protected endpoint returns `401` with a
`WWW-Authenticate` header naming the metadata document, per RFC 9728:

```
HTTP/2 401
WWW-Authenticate: Bearer resource_metadata="https://www.gomry.com/.well-known/oauth-protected-resource"
```

Follow that pointer rather than assuming this file is current.

## Step 2 — Pick a method

| Method | Use when | Credential |
| --- | --- | --- |
| **OAuth 2.1** (`agent_auth`) | An assistant acts as a signed-in Gomry user | Access token via authorization code + PKCE |
| **Signed-in checkout** (`agent_auth`) | An assistant buys tickets for the person who signed in, and cannot hold a client secret | The same OAuth access token, granted `checkout:write` |
| **Personal token** (`agent_auth`) | A user wants to connect one assistant by hand | Bearer token minted in the UI |
| **Organization API key** (`service_auth`) | A backend integration acts as an organization, no user present | `X-API-KEY` with per-resource scopes |
| **Checkout key** (`service_auth`) | Buying a ticket via the Agentic Commerce Protocol | Organization API key with `checkout: write`, plus a delegated payment token |
| **No credential** | Finding a ticket and handing the buyer a page to pay on | None: the catalog and checkout routes accept a request with no `Authorization` header. Paying with a token through `complete` is refused (`401 authentication_required`) |
| **Unattended agent credential** (`service_auth`) | Finding and buying a ticket with NO human anywhere in the loop | Self-registered `client_id` + `client_secret`, exchanged for a `checkout:write` token that also reads the catalog |

If a human can authorize interactively, use OAuth 2.1 — it is the only method
that scopes access to that user's own privileges. Use an organization API key
only for unattended server-to-server work.

An unattended agent credential is the narrowest of these: it belongs to no
organization and no user, and it can do exactly two things: browse the public
catalog and buy a ticket. That is also what makes it safe to hand out without
a human: every organizer-scoped endpoint resolves its subject from your
organization, and you have none, so those endpoints answer `403` no matter
what you send. Checkout resolves the seller from the EVENT you are buying
from, and the catalog lists public events from every organizer, so neither
needs an organization.

## Step 3 — Register

**OAuth clients register dynamically.** No manual onboarding, no email thread:

```
POST https://www.gomry.com/api/mcp/oauth/register
Content-Type: application/json

{
  "client_name": "Your Agent",
  "redirect_uris": ["https://your-agent.example/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

The response carries a `client_id`. Clients are public (`none`) — there is no
client secret, so **PKCE is required**, not optional.

**Organization API keys** are not registered by an agent. A Gomry admin creates
one in the dashboard under Organization Settings and grants per-resource
`read`/`write` scopes. An agent cannot mint one for itself; ask the user to.

**Checkout (ACP) keys** are ordinary organization API keys that carry the
`checkout: write` scope — there is no separate partner credential and no
allowlist to join. A Gomry admin creates one the same way as any other key
(Organization Settings → API keys) and grants that scope. Send it as
`X-API-KEY` or `Authorization: Bearer`.

**Unattended agent credentials register themselves**, with no human and no
dashboard. Ask for the `client_credentials` grant and send NO
`redirect_uris` — this client never reaches a browser, and a redirect target
is rejected rather than ignored:

```
POST https://www.gomry.com/api/mcp/oauth/register
Content-Type: application/json

{
  "client_name": "Your Agent",
  "grant_types": ["client_credentials"]
}
```

The response carries a `client_id` and a `client_secret`. **The secret is
shown once and never again** — only its hash is stored, so if you lose it,
register a new client. `client_credentials` cannot be combined with
`authorization_code` in one registration; register a separate client for each.

**Do not send a `Signature` header** unless Gomry has issued you a signing
secret. The ACP spec makes signatures optional and names the bearer token as
the authentication mechanism; a signature we have no secret to verify is
**rejected**, not ignored, so sending one you cannot be checked on will fail
the request. Signing remains mandatory for the named partners who already have
a secret.

## Step 4 — Claim ceremony

Send the user to the authorization endpoint with a PKCE challenge:

```
GET https://www.gomry.com/api/mcp/oauth/authorize
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=<your callback>
  &code_challenge=<S256 challenge>
  &code_challenge_method=S256
  &scope=mcp
  &state=<opaque>
```

To buy tickets for the person, ask for `checkout:write` as well
(`&scope=mcp%20checkout%3Awrite`). The consent page tells them the agent can
buy tickets for them, and the token response's `scope` says what they
granted. Ask for it only if you will buy: it is the one scope that spends.

The user signs in and approves. Gomry redirects back to your `redirect_uri`
with `code` and your `state`. Verify `state` matches what you sent before
continuing — this is the CSRF binding, and skipping it is the single most
common mistake in an agent OAuth client.

`code_challenge_method` must be `S256`; `plain` is not supported.

## Step 5 — Exchange the assertion

Trade the code for tokens. No client secret — the PKCE verifier is the proof:

```
POST https://www.gomry.com/api/mcp/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<code>
&redirect_uri=<same redirect_uri>
&client_id=<client_id>
&code_verifier=<the PKCE verifier>
```

Returns an `access_token`, a `refresh_token`, and `expires_in`. Refresh
before expiry with `grant_type=refresh_token`. Tokens are signed `RS256` and
verifiable against https://www.gomry.com/.well-known/jwks.json.

A user who prefers not to run the OAuth dance can mint a personal token at
`https://www.gomry.com/api/mcp/token` and paste it into the assistant. It is used exactly
like an access token below.

**An unattended agent skips Step 4 entirely** — there is no user to send
anywhere — and authenticates with its own secret:

```
POST https://www.gomry.com/api/mcp/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=<client_id>
&client_secret=<client_secret>
```

HTTP Basic (`Authorization: Basic base64(client_id:client_secret)`) works too.

Returns an `access_token` scoped `checkout:write` and `expires_in` of 900
seconds. **There is no refresh token** — request a new one with the same secret
when it expires, which is what this grant is for. The scope is fixed by the
server: asking for a wider one changes nothing, and the response always states
what was actually issued.

## Step 6 — Use the access_token

Send it as a bearer token. The MCP server speaks streamable HTTP:

```
POST https://www.gomry.com/api/mcp
Authorization: Bearer <access_token>
Content-Type: application/json
```

For the REST API, a **service credential** is an organization key instead:

```
GET https://www.gomry.com/api/v1/events
X-API-KEY: <organization api key>
```

`Authorization: Bearer <organization api key>` is also accepted on the REST
API.

A **signed-in access token holding `checkout:write`** works on the catalog and
checkout routes, sent the same way:

```
POST https://www.gomry.com/api/v1/checkout_sessions
Authorization: Bearer <access_token>
API-Version: 2025-09-12
Content-Type: application/json
```

The purchase is the person's: send no `buyer` and their verified name and
email are used, and the tickets appear in their account. Organization endpoints
answer `403` to this token. A token without `checkout:write` gets `403
insufficient_scope` with `required_scope: "checkout:write"` (and the same in
`WWW-Authenticate`): have the person connect again with that scope. If they
disconnect your agent, its token stops buying at once (`401 invalid_token`),
even before it expires.

An **unattended agent credential** is sent the same way, and reaches exactly
two families of endpoints. Checkout:

```
POST https://www.gomry.com/api/v1/checkout_sessions
Authorization: Bearer <access_token>
API-Version: 2025-09-12
Content-Type: application/json
```

And the cross-organization catalog (`catalog:read`), which is how you find
something to buy by date, city or keyword:

```
GET https://www.gomry.com/api/v1/catalog/events?starts_after=<YYYY-MM-DD>&starts_before=<YYYY-MM-DD>&city=New%20York
Authorization: Bearer <access_token>
```

Follow `pagination.next_cursor` while `has_more` is true, then
`GET https://www.gomry.com/api/v1/catalog/events/{eventId}` for the event you pick: it
carries each ticket type's id. `/api/v1/catalog/*` is the full list.

Every other `/api/v1` endpoint answers `403 insufficient_scope`, because
they resolve resources inside YOUR organization and this credential has none.
Do not send a `Signature` header: you have no signing secret, and one we
cannot verify is rejected rather than ignored.

### Finding something to buy, with no credential

`items[].id` in a checkout session is a **ticket type id**. The catalog above
returns them; without any credential, two requests get you one:

1. **Find the event.** `POST https://www.gomry.com/ask` with `{"query": "…"}` returns
   schema.org `Event`s, each carrying its page `url`.
2. **Read that event's markdown.** Append `.md` to the URL, or send
   `Accept: text/markdown`:

   ```
   GET https://www.gomry.com/event/<slug>.md
   ```

   It lists every ticket type with its **id**, price, currency, per-order
   limits and whether it is buyable right now — plus the event's registration
   questions and a checkout body filled in with that event's real ids, ready
   to send.

⚠️ Prices in that document exclude the service fee. The session's `totals` is
the only authoritative price; never compute one yourself.

Conventions that apply to every authenticated call:

- Rate limit: 60 requests per 10 seconds per key, then `429`.
- Writes accept an `Idempotency-Key` header; a replay within 24 hours returns
  the original response rather than acting twice.
- List endpoints paginate with `page` and `page_size` (max 200).

## Payment

Two ways to pay, depending on what you can do:

- **You can pay with a Stripe Shared Payment Token** (an agent payment provider,
  a Link wallet): pay through the API as described below. The purchase finishes
  without the buyer leaving your conversation.
- **You can't:** create and price the session exactly the same way (with no
  credential at all, if you have none), then send
  the person you are buying for the session's **`continue_url`**. They pay on
  a hosted page with a card, Apple Pay or Google Pay: no Gomry account, no
  sign-in, no code to relay. The tickets are issued the moment they pay, and you
  read the outcome back from the session.

### Paying with `continue_url` (no payment token)

Every session that is `ready_for_payment` with a total above zero carries
`continue_url`, the standard checkout handoff (UCP and ACP both name it).
Send it to the buyer as it is. It needs no credential: opening it takes them
straight to the payment page for exactly this session's total, prefilled with
the buyer's email. If the session was already paid it opens their tickets; if
the cart can no longer be sold it opens the event page. Then read the outcome
back as described below.

If you would rather hand over the payment page's own URL (to show its expiry,
say), ask for it directly:

```
POST https://www.gomry.com/api/v1/checkout_sessions/{checkout_session_id}/payment_link
Authorization: Bearer <token>   (omit it if you created the session without one)
API-Version: 2025-09-12
```

The body is optional: `{"buyer": {"name": "…", "email": "…"}}` fills or
replaces the session's buyer, whose email the page is prefilled with and the
tickets are sent to. The session must be `ready_for_payment` with a total
above zero (a free order needs no payment: call `complete` with no
`payment_data`). The response is the session, with:

```json
"payment_link": {
  "status": "open",
  "url": "https://checkout.stripe.com/c/pay/cs_…",
  "amount": 1465,
  "currency": "usd",
  "expires_at": "2026-10-14T18:00:00.000Z"
}
```

`url` charges exactly the session total, including fees, and is open for an
hour; `continue_url` keeps working for a day and makes a fresh page when an
old one expired. Asking again for the same total returns the same link;
a changed cart gets a new one and the old one stops working. Then poll
`GET /api/v1/checkout_sessions/{id}` (every few seconds while the buyer is
paying is fine):

- `status: completed` with an `order`: paid, tickets issued.
  `order.permalink_url` is the buyer's ticket page; the tickets are emailed too.
- `payment_link.status: refunded` with `refund_reason`: they paid, but the
  order could no longer be honoured (sold out, or the price moved while they
  were paying), so the payment was refunded in full. Nothing was issued.
- `payment_link.status: expired`: nobody paid in time. Ask for a new link.

Nothing is held while the buyer is on the page, so everything is checked again
when they pay.

Every ticket type in the catalog and in an event's `.md` document also
carries a `checkout_url`: Gomry's own checkout with that ticket selected. It
works without any credential, but asks the buyer to sign in to Gomry with a
code sent to their email, so prefer the payment link when you have a credential.

### Paying through the API

A credential gets you a priced session; it does not get you a charge. Paying
needs one more thing, and it is the step most agents reach unprepared.

```json
{ "payment_data": { "token": "spt_1ABC…", "provider": "stripe" } }
```

`provider` is a literal and must be exactly `"stripe"` — omitting it is a
hard `400`, because the token shape we accept is defined by which provider
minted it. **Raw card data is never accepted on any endpoint**, and a
`payment_data` carrying a `card` field is rejected rather than ignored, so
that cardholder data cannot reach our logs by accident.

**Free tickets need no token at all.** When the session's `total` is `0`,
call `complete` with no `payment_data` (a body of `{}`, or just
`buyer`) and the tickets are issued. A paid session without it is refused
with `400` naming `$.payment_data`.

`token` is a **delegated payment token** — a Stripe Shared Payment Token. An
SPT is scoped to ONE seller's Stripe profile and Stripe refuses it anywhere
else, so it has to be minted payable to Gomry. That takes our **network
business profile id**: it goes in `--network-id` if you issue through
`@stripe/link-cli`, or the equivalent field of whatever mints yours.

**The id depends on the session's currency.** USD sessions are charged by Gomry,
Inc. and every other currency by Gomry Europe BV. They are separate Stripe
accounts with separate profiles, and a token minted for one is refused by the
other. Every checkout session names the one to use in
`payment_provider.network_profile_id`, so read it from the session you are
paying for rather than choosing it yourself.

Gomry's network business profile ids for this deployment:

| Session currency | Network business profile id |
| --- | --- |
| `usd` | `profile_61VNjLqrJYGmIqoSAA6VNjLoQKSQ1zvRU0CLGMI5wAQy` |
| every other currency | `profile_61VU9G4lH2mo1VDefA6VU9G4JfLJZmSJav8F691L6Tdw` |

They identify Gomry as the seller a token is payable to. They are not secrets
and authorize nothing on their own.

### Minting one

Stripe's own path for an agent that does not yet run its own wallet is the
Link CLI, issuing a one-time token from a personal Link account:

```bash
npx @stripe/link-cli auth login
npx @stripe/link-cli payment-methods list

npx @stripe/link-cli spend-request create \
  --payment-method-id csmrpd_… \
  --context "Gomry ticket purchase" \
  --amount <the session total, in minor units> \
  --credential-type shared_payment_token \
  --network-id <the session's payment_provider.network_profile_id> \
  --request-approval
```

`--amount` is the session's `totals` entry of type `total`, verbatim, and
the token's currency must be the session's `currency` — we refuse a mismatch
with `currency_mismatch` rather than converting. Stripe's guidance is that the
agent scopes the token to the transaction total; a token granted for less is refused
as over its granted amount, which is one of the cases behind the 402 below. So
quote first, then mint — an SPT minted before you know the total is the common
way to get a decline on a perfectly good card.

Stripe's seller and agent docs for this:
https://docs.stripe.com/agentic-commerce/concepts/shared-payment-tokens

A token that was granted to a different profile, already used, expired, or
over its granted amount all come back the same way:

```
402 payment_declined — "The delegated payment token could not be charged. It may
be expired, already used, over its granted amount, or issued for a different
account."
```

Shared Payment Tokens are single-use and short-lived, so **mint a fresh one;
do not retry with the same token.** If a `complete` call timed out on your
side and the retry returns this, the FIRST attempt probably succeeded — re-read
the session with `GET` before concluding the sale did not happen.

## Errors

| Status | Body | What it means | What to do |
| --- | --- | --- | --- |
| `401` | `{"error":"Missing API key. Send X-API-KEY or Authorization: Bearer <key>."}` | No credential presented | Read `WWW-Authenticate`, then authenticate |
| `401` | `{"error":"API keys are not accepted in the query string (api_key=…). Send X-API-KEY or Authorization: Bearer <key>."}` | Key sent in the URL (or as Basic auth) | Move it to an `X-API-KEY` or `Authorization: Bearer` header |
| `401` | `{"error":"Invalid or revoked API key"}` | Credential is wrong, expired or revoked | Re-authenticate; do not retry the same key |
| `403` | `{"error":"insufficient_scope","required_scope":"events:write"}` | Authenticated, but the key lacks that scope | Ask the admin to grant the named scope |
| `429` | rate limited | Over 60 requests / 10 seconds | Back off and retry |

`required_scope` names exactly what was missing, so an agent can tell the user
which box to tick rather than reporting a generic failure. The ACP checkout
surface answers in the ACP envelope (`{type, code, message}`) instead, and
deliberately omits `required_scope` — that surface is cross-tenant and its
allowlist must not be enumerable.

Never retry a `401` with the same credential. Re-run the flow that produced it.

## Revocation

Revoke a token you no longer need — on user disconnect, on uninstall, or when
your storage may be compromised:

```
POST https://www.gomry.com/api/mcp/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=<access or refresh token>
&client_id=<client_id>
```

Revoking an **unattended agent credential's** access token through that same
endpoint suspends the client: the credential stops working on its next request
and cannot mint another. There is no refresh lineage to kill, so this is what
"revoke" means for that grant. Gomry may also suspend a credential on its own
if it accumulates declined payments — a `401` after a run of `402`s means
the credential is closed, not that the token expired, and re-registering to
continue the same behaviour is not an acceptable response to it.

Organization API keys are revoked by an admin in the dashboard under
Organization Settings; revocation takes effect immediately and every subsequent
call returns `401 Invalid or revoked API key`. A user may also revoke an
assistant's OAuth grant from their Gomry account, which invalidates its tokens
without any action by the agent — so treat a sudden `401` on a previously
working token as an intentional disconnect, not a transient error.

## Notes

Gomry does not currently implement cross-domain identity assertion
(`identity_assertion` / `id-jag` token exchange, RFC 8693). Agents
authenticate directly with the flows above. If you need federated identity for
an enterprise deployment, contact us at https://www.gomry.com/contact.

- Capabilities and scopes: https://www.gomry.com/agents.md
- Full API reference: https://docs.gomry.com
- Contact: https://www.gomry.com/contact
