# Shopkit OAuth 2.0

With OAuth 2.0 your app can work with many Shopkit stores without asking any of them for an API key. The merchant signs in to Shopkit, picks the store and approves what your app can do. Your app gets tokens for the [Shopkit API](https://shopk.it/developers/api), limited to what was approved.

To connect ChatGPT, Claude or another AI assistant to a store, use the [Shopkit MCP server](https://shopk.it/developers/mcp): it finds this authorization server by itself, so the assistant only needs the server address.

### OAuth or API key

An API key belongs to one store and opens everything the API offers for that store. It suits an integration the merchant sets up for their own store, such as an invoicing or stock system.

Use OAuth when your app serves stores that are not yours: a marketplace connector, a SaaS product, a CLI or desktop tool, an assistant. Each store grants only the areas your app asks for, sees the app in its admin and can reduce its access or disconnect it at any time, and your app never handles a secret of the store.

### Endpoints

| Endpoint | Address |
|---|---|
| Issuer | `https://shopk.it` |
| Server metadata (RFC 8414) | `https://shopk.it/.well-known/oauth-authorization-server` |
| Authorization | `https://shopk.it/oauth/authorize` |
| Authorization with the store preselected | `https://STORE.shopk.it/admin/oauth/authorize` (forwards to the authorization above) |
| Token | `https://api.shopk.it/oauth/token` |
| Revocation (RFC 7009) | `https://api.shopk.it/oauth/revoke` |
| API v1 | `https://api.shopk.it/v1` |

The metadata document lists the endpoints, the supported grant types (`authorization_code`, `refresh_token`), the PKCE method (`S256`), the client authentication methods (`none`, `client_secret_basic`, `client_secret_post`), every scope, `authorization_response_iss_parameter_supported: true` and `client_id_metadata_document_supported: true`. Clients that discover the server read it instead of hardcoding the addresses above.

```bash
curl -A "MyApp/1.0" https://shopk.it/.well-known/oauth-authorization-server
```

### Register your app

Any store whose plan includes the API can register apps in its admin, under **Configurações › Developers › OAuth**, with a user who can edit Developers. The app then works with every Shopkit store whose plan includes the API, not only the one that registered it. A store on a plan without the API cannot connect apps: the merchant sees an error page. Pick the type:

- **Privada** (confidential client): a server that can keep a secret. You get a `client_id` and a `client_secret`. The secret is shown only once, right after you create the app, so store it safely. If you lose it, renew it on the page of the app: the old one stops working at once.
- **Pública** (public client): a CLI, a desktop app or an assistant, which cannot keep a secret. You get only a `client_id`, and nothing else proves the identity of the app besides PKCE and the registered redirect URIs.

PKCE (`S256`) is required for both types.

The type cannot be changed after the app is created. The name (up to 100 characters) is the one merchants see when they grant access. The logo is optional: on the page of the app, pick an image from the store's Multimédia. It appears on the authorization screen. Add up to 5 redirect URIs; you can change them, and the name and the logo, later on the page of the app:

- Each one must use `https`, up to 500 characters and without a fragment (`#`).
- `http` is accepted only for loopback addresses: `localhost`, `127.0.0.1` and `[::1]`.
- The `redirect_uri` of a request must match a registered one exactly. For loopback addresses any port is accepted (RFC 8252), as long as the host, path and query match, so a CLI can listen on a free port.

Deleting an app in the admin disconnects it from every store that authorized it, together with the webhooks it created.

### Client ID Metadata Document

A public client can skip registration and use as `client_id` an `https` URL that serves its metadata as JSON. This is how MCP clients such as ChatGPT and Claude identify themselves.

The URL must use `https`, have a path other than `/`, have no fragment, user or password, no `.` or `..` segments and at most 255 characters. Shopkit fetches it on every authorization request, without following redirects, with a 5 second timeout and a 10 KB limit, and expects a `200` response. The host must resolve to a public IP address.

```json
{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "My App",
  "redirect_uris": ["http://127.0.0.1/callback", "https://app.example.com/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"]
}
```

- `client_id` must be exactly the URL of the document.
- `redirect_uris` is required and follows the same rules as a registered app: up to 5, `https` or `http` loopback.
- `token_endpoint_auth_method` (or `token_endpoint_auth_methods_supported`) must include `none`, which is also the default. These clients never send a secret.
- `grant_types` is optional; when present it must include `authorization_code`.
- `client_name` is optional, trimmed to 100 characters. Without it the merchant sees the host of the URL.
- `logo_uri` is optional: an `https` URL of up to 2048 characters, shown on the authorization screen. Shopkit reads it with the document and does not store it.

Authorization requests with a URL `client_id` are limited to 30 per hour per IP address. Past the limit, Shopkit answers `429` with a `Retry-After` header and the merchant never returns to your app.

### Authorization request

Every client uses the authorization code flow with PKCE (RFC 7636). `S256` is the only method and it is required, confidential clients included. Create a random `code_verifier` (43 to 128 characters: letters, digits, hyphen, period, underscore and tilde) and its challenge:

```bash
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '=+/\n')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
  | openssl dgst -sha256 -binary | openssl base64 -A \
  | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
```

Then send the merchant's browser to the authorization endpoint:

```text
https://shopk.it/oauth/authorize?response_type=code
  &client_id=CLIENT_ID
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=products%3Aread%20orders%3Awrite
  &state=STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
  &resource=https%3A%2F%2Fapi.shopk.it
```

| Parameter | Description |
|---|---|
| `response_type` | Required. Always `code`. |
| `client_id` | Required. The `client_id` of your app, or the URL of your Client ID Metadata Document. |
| `redirect_uri` | Required. One of the redirect URIs of the app. Send the same value to the token endpoint. |
| `code_challenge` | Required. The base64url SHA-256 of the `code_verifier`, 43 characters. |
| `code_challenge_method` | Required. Always `S256`. |
| `scope` | Optional. Scopes separated by spaces, see Scopes. Without it the app asks for `read` on every module. |
| `state` | Recommended. Returned unchanged in the redirect; without it the redirect has no `state`. Use a random value tied to the user's session and check it on return. |
| `resource` | Optional. Where the token will be used (RFC 8707), see Resource and audience. |

If the `client_id` is unknown or the `redirect_uri` is not one of the app's, Shopkit shows an error page and never redirects. For a registered app, other problems go back to the `redirect_uri` with an `error`; for a Client ID Metadata Document they also end on the error page, since the document chooses its own redirect URIs. The errors are `unsupported_response_type`, `invalid_scope`, `invalid_request` (missing or invalid PKCE) or `invalid_target` (invalid `resource`).

#### What the merchant sees

Shopkit users belong to one store each. At `https://shopk.it/oauth/authorize` the merchant:

1. Signs in with their Shopkit e-mail and password, which are checked against every store where that e-mail has a user.
2. Picks the store, when the account has users in more than one. With a single store this step is skipped.
3. Types the two-factor code of that store's user, when the user has one (authenticator or backup code).
4. Approves on the consent screen.

A store that requires two-factor authentication stops staff who have not set it up yet: they see an error page that says where to turn it on. Owners are exempt.

If your app already knows the store, send the merchant to `https://STORE.shopk.it/admin/oauth/authorize` with the same parameters: it forwards to the same authorization with that store preselected. The store picker is skipped only when the signed-in account has a user in that store. The sign-in and the two-factor code are skipped only when the merchant is signed in to that store's own admin in the same browser (a support session does not count): the admin hands over a single-use ticket, valid for 1 minute and tied to your `client_id` and `redirect_uri`. Otherwise the merchant goes through all the steps, and may end up in another store. To know which store was authorized, call `GET /v1/store` with the token, not the address you used.

The store's plan must include the API. The consent screen shows the logo, name and type (public or private) of the app, whether it is managed by Shopkit ("Gerida pela Shopkit") or not ("Não é gerida pela Shopkit"), the store, the signed-in user (with **Mudar de conta** to sign in with another), what the app asks for, a note that the app also gets the user's name and e-mail, the `resource` when you sent one, and the host of your `redirect_uri` ("Depois de decidires, voltas para HOST"). It does not show the host of a Client ID Metadata Document. A staff member can only grant what their account can do in the store: `write` needs the edit permission of the module, and with only the view permission it becomes `read`. Modules the account cannot see are left out and listed on the screen, and if nothing is left the authorization stops there. The store owner can grant everything.

Several failures end on a Shopkit error page and never redirect to your app: a store without the API in its plan, staff who must still set up two-factor authentication, an account with none of the permissions your app asks for, a request that expired (10 minutes per step), too many attempts (`429`) and a Client ID Metadata Document that cannot be validated. Your app must cope with a merchant who never comes back, for example by expiring the pending `state`.

#### Redirect back to your app

```text
https://app.example.com/callback?code=skc_...&state=STATE&iss=https%3A%2F%2Fshopk.it
```

If the merchant declines, the redirect carries `error=access_denied`. Every redirect, with a code or an error, includes `iss` (RFC 9207): check that it is `https://shopk.it` and that `state` is the one you sent. The code is valid for 10 minutes and works once. After approval the merchant sees a confirmation page and the browser goes back to your app within a couple of seconds.

Approving again for the same app, store and user replaces the previous authorization, and the tokens issued before stop working. Two users of the same store who approve the app have one authorization each.

### Resource and audience

A token works in one place only, chosen by `resource` in the authorization request:

| `resource` | The token works on |
|---|---|
| omitted, or `https://api.shopk.it` | The Shopkit API v1 |
| `https://mcp.shopk.it/mcp` (or `https://mcp.shopk.it`) | The [Shopkit MCP server](https://shopk.it/developers/mcp) only. The API refuses these tokens with 401. |

A path under one of these addresses is accepted too. Any other value returns `invalid_target`. Tokens issued without `resource` do not work on the MCP server.

At the token endpoint, `resource` is optional and is compared with the one of the authorization as written, ignoring a trailing slash. If the authorization request had no `resource`, do not send one: even `https://api.shopk.it` returns `invalid_target`.

### Token endpoint

Send `POST` requests to `https://api.shopk.it/oauth/token` with an `application/x-www-form-urlencoded` body. Client authentication:

- **Private apps** send the secret with HTTP Basic (`client_secret_basic`, with `client_id` and `client_secret` form-encoded before base64, as in RFC 6749) or as `client_id` and `client_secret` in the body (`client_secret_post`).
- **Public apps** and Client ID Metadata Documents send only `client_id` in the body (`none`). A public app that sends a secret is refused.

#### Exchange the code

```bash
curl -X POST https://api.shopk.it/oauth/token \
  -A "MyApp/1.0" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=skc_... \
  --data-urlencode redirect_uri=https://app.example.com/callback \
  -d code_verifier="$CODE_VERIFIER"
```

A public app replaces `-u` with `-d client_id=CLIENT_ID`. `code`, `redirect_uri` and `code_verifier` are required. `resource` is optional, see Resource and audience.

```json
{
  "access_token": "ska_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "skr_...",
  "scope": "orders:write products:read"
}
```

The access token is valid for 1 hour and the refresh token for 90 days. `scope` is what the merchant actually granted, which can be less than you asked for (a module the user cannot see is left out, `write` becomes `read` for a user who cannot edit it, and a token for the [MCP server](https://shopk.it/developers/mcp) only gets the scopes its tools use): always read it from the response and adapt. Responses carry `Cache-Control: no-store`.

#### Refresh the access token

```bash
curl -X POST https://api.shopk.it/oauth/token \
  -A "MyApp/1.0" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token=skr_...
```

The response has the same format, with a new access token and a new refresh token for the same resource. Each refresh token works once: store the new one before using it. Each refresh starts a new 90 days. The access token you had before keeps working until it expires. The new pair never does more than the store allows at that moment: if the merchant reduced the access, the response's `scope` has the reduced scopes, and if the merchant gives the access back, the new tokens get it back too.

Presenting a code or a refresh token that was already used revokes the whole authorization, as RFC 6749 recommends: the app loses access to that store, its webhooks there are deleted and the merchant has to approve it again. Refresh one token at a time.

#### Errors

Errors follow RFC 6749, with `error` and `error_description`:

```json
{
  "error": "invalid_client",
  "error_description": "Client authentication failed."
}
```

| Status | `error` | When |
|---|---|---|
| 400 | `invalid_request` | A required parameter is missing, including `grant_type`. |
| 400 | `unsupported_grant_type` | `grant_type` is sent but is not `authorization_code` or `refresh_token`. |
| 400 | `invalid_grant` | The code or refresh token is invalid, expired, already used, revoked or belongs to another app, or the `redirect_uri` or `code_verifier` do not match. |
| 400 | `invalid_target` | `resource` is not the one of the authorization. |
| 401 | `invalid_client` | Client authentication failed. The response includes `WWW-Authenticate: Basic realm="Shopkit"`. |
| 405 | `invalid_request` | The request was not a `POST`. The response includes `Allow: POST`. |

Both endpoints answer browser requests (CORS) from any origin, so a public client running in a browser can call them.

### Revoke a token

```bash
curl -X POST https://api.shopk.it/oauth/revoke \
  -A "MyApp/1.0" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d token=skr_...
```

The revocation endpoint (RFC 7009) authenticates the client like the token endpoint and answers `200` with an empty body. `token` is required (without it the answer is `400 invalid_request`). Revoking a refresh token ends the whole authorization, with every token issued for it and the webhooks your app created in that store. Revoking an access token ends only that token. The type is read from the token itself, so `token_type_hint` is ignored. Authorization codes cannot be revoked. Unknown tokens and tokens of other apps are ignored with the same answer. Revoke the refresh token when the merchant disconnects the store in your app.

### Calling the API

Send the access token in the `Authorization` header. Always send a `User-Agent`: Shopkit's firewall blocks the IP address of clients that send requests without one, before they reach the API.

```bash
curl "https://api.shopk.it/v1/product?limit=10" \
  -A "MyApp/1.0" \
  -H "Authorization: Bearer ska_..."
```

Do not send an API key in the same request, as the `X-API-KEY` header or the `?X-API-KEY=` query parameter: when one is present, the API uses the key and ignores the token. `GET /v1/store` works with any token, whatever the scopes, and tells you which store the token belongs to; with a token it returns the store without its private settings.

Changes your app makes appear in the store's history as made by the user who approved it, "via" your app's name.

#### Effective permissions

On every request, what a token can do is the intersection of three things: the scopes of the token, the scopes the store keeps for the authorization now, and the current permissions of the user who approved it. If the merchant reduces the access, or the user loses a permission, the token follows at once. A `DELETE` also needs the user's delete permission in that module. The token stops working if the user is deactivated, the store is deactivated or the store's plan no longer includes the API.

#### Errors

| Status | When |
|---|---|
| 401 | The token is invalid, expired or revoked, or was issued for another resource. The body is `{"message": "Bad credentials"}` and the response includes `WWW-Authenticate: Bearer error="invalid_token"`: refresh the token, or ask the merchant to authorize again if the refresh fails. |
| 403 | The request is outside the token's scopes, with `"error": "insufficient_scope"` in the body and `WWW-Authenticate: Bearer error="insufficient_scope"`. Without that field, the request was refused for another reason: the user who approved the app no longer has the permission, the store's plan does not include that feature, or you tried to delete a webhook that is not yours. |
| 429 | The authorization used up its hourly request limit. The body is `{"success": false, "message": "This API key has reached the time limit for this method"}`. There is no `Retry-After` header. |

```json
{
  "message": "The access token does not have the scope required for this request",
  "error": "insufficient_scope"
}
```

### Scopes

Scopes have the form `{module}:read`, for `GET` requests, and `{module}:write`, for `GET`, `POST`, `PUT` and `DELETE`.

| Module | API endpoints |
|---|---|
| `orders` | `/order` (with events, invoice and PDF), `/shipping`, `/shipment` |
| `products` | `/product` (with search, options, extra options, tabs, custom fields and quantity tiers), `/category`, `/brand`, `/extra_option`, `/extra_option_scheme`, `/custom_field` |
| `clients` | `/client` |
| `promotions` | `/coupon` |
| `blog` | `/blog/post`, `/blog/tag`, `/blog/author` |
| `pages` | `/page` |
| `stats` | `/stats` |
| `appearance` | `/theme`, `/theme/field` (the store's theme editor; the approving user needs full access to Appearance) |
| `media` | `/media` |
| `domains` | `/redirect` |
| `developers` | `/webhook` |

### Webhooks

With the `developers:write` scope your app can subscribe to store events with `POST /v1/webhook`, for the areas it can read. The area depends on the event:

| Events | Scope needed |
|---|---|
| `order_*`, `shipping_*` and `coupon_*` | `orders:read` |
| `product_*` | `products:read` |
| `client_*`, `newsletter_*` and `cart_abandoned_notify` | `clients:read` |

An event outside those areas answers `403` with `insufficient_scope`. A webhook your app creates is tied to its authorization:

- The response of `POST /v1/webhook` includes `secret`, only that once. Store it with the webhook's `id`: the webhook's `X-Webhook-Signature` is the HMAC-SHA256 of the request body with that secret, not with the store's client secret, which your app never sees.
- `POST /v1/webhook` answers `409` when the store already has a webhook for the same `url` and event, whoever created it.
- The payload does not include `shipping_data`, which only installed apps receive.
- Disconnecting your app in the store, revoking its refresh token, deleting the app and presenting a code or refresh token a second time delete its webhooks in that store. So does the merchant reducing the permissions of the app, for the webhooks the remaining permissions no longer cover. When the same user approves the app again, the webhooks the new permissions still cover are kept.
- While the user who approved the app is deactivated or no longer can view the area of an event, the webhook does not deliver that event. It is not deleted and delivers again when the user can.
- `GET /v1/webhook` lists every webhook of the store, but the ones your app did not create come without the query string of their `url`, where other integrations keep their credentials. `DELETE /v1/webhook/{id}` answers `403` for the webhooks of installed apps and for those created by another authorization, including another OAuth app. Webhooks created with an API key or in the admin can be deleted.

```php
hash_equals($request_signature, hash_hmac('sha256', $payload, $webhook_secret));
```

The events, payloads and headers are described in [Webhooks](https://shopk.it/developers/webhooks).

### Limits

- API requests with OAuth tokens have the hourly limit of the store's plan, counted separately for each authorization, so your app does not use up the store's own API key limit.
- Authorization requests with a URL `client_id`: 30 per hour per IP address.
- Sign-in forms sent to `https://shopk.it/oauth/login`: 10 per 5 minutes per IP address. The two-factor code has its own limit, also 10 per 5 minutes per IP address.
- Each step of the authorization screens expires after 10 minutes; after that the merchant starts again from your app.

Past a limit, the answer is `429`; wait before trying again.

### What the merchant controls

In **Apps › Conectadas**, the store sees one row for each authorization (an app and the user who approved it), with the app, its permissions (how many modules it can view and how many it can edit), when it was connected (the last approval) and when it was last used: the date of the newest token issued to it, by the code exchange or a refresh, not of its last API call. Who approved it is on the page of the app. Opening an app there, the merchant can reduce its access (unticking permissions and saving with **Gravar dados**), changing a module from write to read or removing it, give back access it removed, up to what was approved when the app was connected and what the user who saves could approve, and can disconnect it altogether (**Desconectar**, which revokes its access and deletes its webhooks). Webhooks deleted when the access was reduced do not come back. To get more access than was approved, the app has to ask for a new authorization.

For the endpoints, parameters and responses of the API, see the [API reference](https://shopk.it/developers/api).
