Connected apps & OAuth
A connected app acts for one Cru customer, with their permission: an AI assistant, a portfolio tool, anything a customer signs in to with their own Cru account. It gets a token through OAuth 2.1 — the customer approves it on Cru’s site — and every trade it prepares waits for the customer to confirm.
Integrating your own business server-to-server? Use the client key and secret Cru issues you. Partner credentials →
1 / 5A call without a token gets 401, and WWW-Authenticate points to the metadata that names Cru’s authorization server.
Connecting, end to end
5 STEPSYour app
Discover the authorization server
GET /.well-known/oauth-authorization-serverAn unauthenticated call answers 401 with WWW-Authenticate naming the protected resource metadata, which names the authorization server and its endpoints.
KEEP
registration_endpoint, authorization_endpoint, token_endpointYour app
Register your app
POST /api/oauth/registerDynamic client registration is open, and grants nothing until a customer approves. Send your redirect URIs and name.
KEEP
client_id, client_secretThe customer
The customer signs in and approves
Send them to /api/oauth/authorize with a PKCE S256 challenge. They choose which permissions and which Cru sites your app gets.
Your app
Exchange the code for tokens
POST /api/oauth/tokenWithin 5 minutes, with the PKCE verifier. The access token lasts an hour; the refresh token 30 days, and rotates on every use.
KEEP
access_token, refresh_tokenYour app
Call the API as the customer
GET /api/v1/pingAuthorization: Bearer on /api/v1 or /api/mcp, on a market host the customer allowed. Writes are staged for the customer to confirm.
What the server implements
The authorization code grant with PKCE for every client, refresh token rotation, dynamic client registration (RFC 7591), revocation (RFC 7009), server metadata (RFC 8414), protected resource metadata (RFC 9728), resource indicators (RFC 8707) and iss on the authorization response (RFC 9207). Tokens are opaque. There is no OpenID Connect, no introspection endpoint and no client credentials grant.
Each market is its own issuer
Every market hostname serves its own authorization server, and its issuer is that hostname: https://us.cruworldwine.com for the market selected above. Connect on the host whose market the customer trades in. The customer can let the app use their other Cru sites too, and the token then works on those hosts as well.
Connected apps are enabled by Cru environment by environment. Where they are not yet, the discovery documents, the OAuth endpoints and /api/mcp answer NOT_FOUND / 404, and a bearer token on /api/v1 is refused.
Discovery
A request without a token gets 401 and a WWW-Authenticate header that points at the protected resource metadata — which is how an MCP client finds its way in without being told anything but the URL.
HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="cru", resource_metadata="https://us.cruworldwine.com/.well-known/oauth-protected-resource/api/v1"
/.well-known/oauth-protected-resource/api/v1 | The REST API as a resource: its authorization server and scopes. |
/.well-known/oauth-protected-resource/api/mcp | The MCP server, likewise. |
/.well-known/oauth-authorization-server | The endpoints, and what they support: code only, S256 only, authorization_code and refresh_token grants. |
{ "issuer": "https://us.cruworldwine.com", "authorization_endpoint": "https://us.cruworldwine.com/api/oauth/authorize", "token_endpoint": "https://us.cruworldwine.com/api/oauth/token", "registration_endpoint": "https://us.cruworldwine.com/api/oauth/register", "revocation_endpoint": "https://us.cruworldwine.com/api/oauth/revoke", "response_types_supported": [ "code" ], "response_modes_supported": [ "query" ], "grant_types_supported": [ "authorization_code", "refresh_token" ], "code_challenge_methods_supported": [ "S256" ], "token_endpoint_auth_methods_supported": [ "none", "client_secret_post", "client_secret_basic" ], "scopes_supported": [ "meta:read", "market:read", "wines:read", "portfolio:read", "bids:read", "offers:read", "trades:read", "addresses:read", "bids:write", "offers:write", "orders:write", "addresses:write" ], "authorization_response_iss_parameter_supported": true }
Register your app
Registration is open to anyone and grants nothing on its own: a customer still has to approve the app. POST /api/oauth/register with a JSON body:
curl -X POST https://us.cruworldwine.com/api/oauth/register \ -H 'Content-Type: application/json' \ -d '{ "client_name": "Example Cellar Tracker", "redirect_uris": ["https://app.example.com/oauth/callback"], "token_endpoint_auth_method": "client_secret_basic" }'
redirect_urisrequired | One to ten. https://, or http:// only on a loopback address (localhost, 127.0.0.1, [::1]), which matches on any port. An app’s own scheme such as myapp:// is allowed; javascript, data, file and similar are not. No fragments. |
token_endpoint_auth_method | client_secret_basic (the default), client_secret_post, or none for a public client with no secret. Use the one you registered, always. |
grant_types | Defaults to both authorization_code and refresh_token. |
client_name | Up to 100 characters, shown to the customer on the consent page. |
client_uri | An https:// link to your app. Dropped if it is anything else. |
The answer is 201 with a client_id (cru_oc_…) and, unless you chose none, a client_secret (cru_cs_…) that does not expire and is shown only this once.
Send the customer to approve it
https://us.cruworldwine.com/api/oauth/authorize ?response_type=code &client_id=cru_oc_YOUR_CLIENT_ID &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback &scope=meta%3Aread%20market%3Aread%20portfolio%3Aread%20bids%3Aread%20bids%3Awrite &code_challenge=YOUR_S256_CHALLENGE &code_challenge_method=S256 &state=YOUR_STATE
code_challenge, code_challenge_method=S256 | Required, for every client. The challenge is the base64url SHA-256 of a 43–128 character verifier you keep for the token request. plain is refused. |
redirect_uri | Exactly as registered. It may be left out only if you registered just one. |
scope | Space-separated. Ask for nothing and you get the read scopes but addresses:read, which you must ask for by name. offline_access is accepted and ignored; scopes Cru does not offer apps are dropped. The scopes → |
resource | Optional: https://us.cruworldwine.com/api/v1 or https://us.cruworldwine.com/api/mcp. A token issued for one works only there; a token issued without one works on both. |
state | Up to 2048 characters, returned unchanged. |
What the customer sees
The customer signs in to their Cru account if they are not already, on that market’s site. The consent page names your app — by the client_name you registered, which Cru cannot verify — and the address it will return them to, and asks two things:
- What it can see and do. The permissions you asked for, in two groups. The read permissions start ticked;
meta:readcannot be unticked. The trading permissions andaddresses:writestart unticked even when you asked for them, with the promise that each bid, sale, purchase or address is shown to the customer first and only happens when it is separately confirmed. - Which Cru sites it can use. The current one always, others if they tick them.
The customer can only take permissions away from what you asked for, never add to it. If they decline — or allow with nothing ticked — you get error=access_denied. The page stays answerable for 15 minutes. An account on hold cannot connect apps at all.
The redirect back
On approval the customer is sent to your redirect_uri with code, state and iss. Check iss is the issuer you started with. The code is single use and lasts five minutes.
Exchange the code
curl -X POST https://us.cruworldwine.com/api/oauth/token \ -u "$CRU_OAUTH_CLIENT_ID:$CRU_OAUTH_CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code=cru_ac_THE_CODE \ -d code_verifier=YOUR_PKCE_VERIFIER \ -d redirect_uri=https://app.example.com/oauth/callback
{ "access_token": "cru_at_…", "token_type": "Bearer", "expires_in": 3600, "scope": "meta:read market:read portfolio:read bids:read bids:write", "refresh_token": "cru_rt_…" }
| Access tokencru_at_… | One hour. Send it as Authorization: Bearer. |
| Refresh tokencru_rt_… | 30 days, and rotated: every refresh returns a new pair and spends the old refresh token. |
| Authorization codecru_ac_… | Five minutes, once. |
Refresh
curl -X POST https://us.cruworldwine.com/api/oauth/token \ -u "$CRU_OAUTH_CLIENT_ID:$CRU_OAUTH_CLIENT_SECRET" \ -d grant_type=refresh_token \ -d refresh_token=cru_rt_THE_REFRESH_TOKEN
A refresh may narrow the scopes, never widen them. Use each refresh token once: presenting one that has already been used revokes the whole connection — every token issued since the customer approved — on the assumption that it was stolen. So does replaying an authorization code. If the customer’s account goes on hold, the next refresh ends the connection for good.
Disconnect
POST /api/oauth/revoke with token= and your client authentication ends the whole connection, whichever of the two tokens you send. It always answers 200 {}, so it cannot be used to test whether a token exists. Customers cannot yet disconnect an app from their Cru account themselves: they ask Cru, which can also suspend an app outright.
Calling the API with a token
The REST API and the MCP server accept the token on any market host the customer allowed. It acts as that customer, with the scopes they granted:
- Reads behave exactly as for a partner.
GET /pingshows the app’s name, the scopes, the market you called and when the token expires. - Writes are staged and answer
202; nothing happens until you confirm the code with the customer’s agreement. Writes & confirmations → - An expired, revoked or wrong-resource token is
AUTH_INVALID_CREDENTIALS / 401, witherror="invalid_token"inWWW-Authenticate. A missing scope addserror="insufficient_scope"and the scope it needs. - A host the customer did not allow is
AUTH_STORE_NOT_ALLOWED; an account on hold,AUTH_ACCOUNT_ON_HOLD.
OAuth errors
The OAuth endpoints answer in OAuth’s own format, {"error", "error_description"}, not the API envelope. Before the client and redirect are known to be valid, the authorization page shows the customer an error instead of redirecting.
invalid_request | 400 | A parameter is missing or malformed — most often a missing PKCE challenge or verifier. 403 when the request was not HTTPS. |
invalid_client | 401 | Unknown or suspended client, wrong secret, or a client authenticating differently from how it registered. |
invalid_grant | 400 | The code or refresh token is invalid, expired, already used or revoked; the redirect or PKCE verifier does not match; or the account can no longer connect apps. |
invalid_scope | 400 | Nothing requested is grantable, or a refresh asked for more than was approved. |
invalid_target | 400 | resource is not /api/v1 or /api/mcp on this host. |
access_denied | redirect | The customer declined, ticked nothing, or has an account that cannot connect apps. |
unauthorized_client | 400 | The client did not register the grant it is using. |
unsupported_grant_type | 400 | grant_type is not authorization_code or refresh_token. |
unsupported_response_type | redirect | response_type is not code. |
invalid_redirect_uri | 400 | Registration: a redirect URI breaks the rules above. |
invalid_client_metadata | 400 | Registration: the body is not a JSON object, or a field is invalid. |
server_error | 500 | Cru’s to fix. |