> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shoply.gg/llms.txt
> Use this file to discover all available pages before exploring further.

# Apps and OAuth

> Build an app any seller can connect to their store, with OAuth, and see the apps connected to yours

An app is something you build that other sellers connect to their own stores: a stock sync, an accounting export, a Discord bot. A seller connects it with two presses on a Shoply page, and your app is handed a token that works like an API key of their store.

Your own store needs none of this. For your own server, make an [API key](/guides/api-keys.md).

## How it works

1. You send the seller to Shoply's approval page with your app's client ID.
2. They pick one of their stores and press **Allow Access**. Shoply sends them back to your app with a code.
3. Your server swaps the code for a token.
4. The token is a key of their store, with the permissions your app asked for, until they disconnect your app.

This is OAuth 2.0's authorization code flow, with PKCE. Any OAuth library that takes an authorization address and a token address works with it.

## Make an app

In the dashboard, open **Developers**, **OAuth Apps**, then **Your Apps**, and press **Add App**.

| | |
| - | - |
| **Name** and **Logo** | What sellers see when they approve it. |
| **Made By** | You, or your company. Sellers see it beside the name. |
| **Website** | Your app's site, on https. Sellers see its address. |
| **Where It Runs** | **On a Server** keeps a client secret. **On a Computer or Phone** has no secret and signs in with PKCE. It cannot be changed afterwards. |
| **Redirect URLs** | Where sellers are sent back after they answer. Each is matched exactly. https, or `http://localhost` for testing. |
| **Permissions** | Everything your app asks for. A seller approves all of it or nothing, so ask only for what it needs. |
| **Disconnect Notices** | Optional. An address Shoply posts to when a seller disconnects your app. See [When a seller disconnects](#when-a-seller-disconnects). |

Adding it shows its **Client ID** and **Client Secret**. The client ID is not a secret: it is in the address sellers are sent to. The client secret is shown this once. Keep it on your server, never in a page or an app people can download. If you lose it, **New Client Secret** in the app's menu replaces it, and the old one stops working at once.

An account can make up to 10 apps. Any seller can connect any of them. Shoply does not review apps, and the approval page says so.

## 1. Send the seller to approve it

```
https://dashboard.shoply.gg/oauth/authorize/?response_type=code&client_id=shoply_app_...&redirect_uri=https%3A%2F%2Fyourapp.com%2Fshoply%2Fback&state=...
```

| | |
| - | - |
| **response\_type** | `code`. |
| **client\_id** | Your app's client ID. |
| **redirect\_uri** | One of your app's redirect URLs, exactly as you added it. |
| **state** | A random value you keep with the seller's session, up to 500 characters. It comes back unchanged: check it is yours, so nobody can send a seller back to you with a code of their own. |
| **code\_challenge** | For PKCE: the base64url of the SHA-256 of a verifier you keep. Always for a desktop or phone app. |
| **code\_challenge\_method** | `S256`, with a challenge. |

The seller signs in if they need to, picks one of their stores, and sees your app's name, logo, maker, website and every permission it asks for. **Allow Access** asks them to confirm it is them, as making an API key does. Only a store's owner can approve.

If your client ID does not exist, or the redirect URL is not one of your app's, the page says so and sends the seller nowhere.

## 2. The seller comes back

Approved, they come back to your redirect URL with a code and your state:

```
https://yourapp.com/shoply/back?code=4f1c...&state=...
```

Cancelled, or with something wrong in what you sent, they come back with `error` and your state instead:

| `error` | Why |
| - | - |
| `access_denied` | The seller pressed **Cancel**. |
| `invalid_request` | The state was over 500 characters, the challenge was not a SHA-256's base64url or its method not `S256`, or a desktop or phone app sent no challenge. |
| `unsupported_response_type` | `response_type` was not `code`. |

`error_description` says it in a sentence.

## 3. Swap the code for a token

From your server, within 10 minutes, once:

```bash theme={null}
curl https://api.shoply.gg/v1/oauth/token \
  -u "shoply_app_...:shoply_cs_..." \
  -d grant_type=authorization_code \
  -d code=4f1c... \
  -d redirect_uri=https://yourapp.com/shoply/back
```

The client ID and secret go by HTTP Basic, or as `client_id` and `client_secret` in the body. The body may be a form or JSON. With PKCE, send `code_verifier` too. A desktop or phone app sends `client_id` and `code_verifier`, and no secret.

```json theme={null}
{
  "access_token": "shoply_at_...",
  "token_type": "Bearer",
  "scope": "products.read orders.read",
  "store_id": "4b849d35-65c2-43a3-8ede-1bc2730303eb"
}
```

These answers are OAuth's own shape, not the API's `success` and `data`. A refusal is `error` and `error_description`:

| `error` | Why |
| - | - |
| `invalid_client` (401) | The client ID or secret is not right. |
| `invalid_grant` (400) | The code is wrong, was swapped already, is older than 10 minutes, or was for another redirect URL or verifier. Send the seller to approve again. |
| `unsupported_grant_type` (400) | `grant_type` was not `authorization_code`. |

A code offered a second time is taken as one somebody else saw on its way, and the token it was swapped for stops working too.

## 4. Use the token

The token is a key of the seller's store. Send it as any key is sent: `Authorization: Bearer shoply_at_...`.

* It reaches what the seller approved and nothing more. A route that needs another permission is refused with `missing_scope`.
* It works from anywhere, with no last day, until the seller disconnects your app.
* [Get the current key](/api-reference/api-key/get-the-current-key.md) answers the store it is for, and `app`, your app. Everything a key is held to holds for it: its [rate limits](/api-reference/rate-limits.md), and the store's record of requests, where it is named after your app.
* What only the dashboard does, behind "Confirm it's you", no token does.

A seller who approves your app again for the same store replaces its token: the old one stops, and the webhook endpoints it made carry over to the new one.

## PKCE, and apps on a computer or phone

A desktop or phone app cannot keep a secret: anybody with a copy can read one out of it. So it has none, and proves the code is its own with PKCE instead:

1. Make a verifier, 43 to 128 letters, digits and `-._~`, and keep it.
2. Send its challenge, the base64url of its SHA-256 with no padding, as `code_challenge`, with `code_challenge_method=S256`.
3. Send the verifier as `code_verifier` with the code.

A desktop app usually listens on this computer for the seller to come back. Add `http://127.0.0.1/callback` as a redirect URL, and send `http://127.0.0.1:<any port>/callback`: the port is yours to pick each time. The address and path must match.

A web app may use PKCE as well as its secret. When it sends a challenge, the verifier is asked for too.

## When a seller disconnects

A seller sees every app connected to a store under **Developers**, **OAuth Apps**, **Connected**, with what it may do and when it was last used, and ends one with **Disconnect**. Then:

* Its token stops working at once.
* The [webhook endpoints](/guides/webhooks.md) it made with its token are deleted. The store's own endpoints stay.
* The store's own server gets `app.disconnected`, as it got `app.connected`. See [Webhook events](/api-reference/webhook-events.md).
* Your app is told, if it has an address under **Disconnect Notices**.

The notice is a POST in a webhook's shape, signed as a webhook is, with your app's notice secret (shown once when you add the address, and replaced with **New Notice Secret**):

```json theme={null}
{
  "id": "1b6f...",
  "event": "app.disconnected",
  "createdAt": "2026-10-21T13:04:59Z",
  "storeId": "4b849d35-65c2-43a3-8ede-1bc2730303eb",
  "data": {
    "id": "c2e7...",
    "appId": "8d0a...",
    "name": "Stocklink",
    "maker": "Northwind Labs",
    "website": "https://stocklink.example",
    "scopes": ["products.read", "orders.read"],
    "connectedAt": "2026-10-21T10:00:00Z",
    "disconnectedAt": "2026-10-21T13:04:59Z",
    "reason": "seller"
  }
}
```

Check it as you would a webhook: `webhooks.verify` in the SDKs takes the notice secret. `reason` is `seller`, or `code_reused` when Shoply ended it because its code was offered twice. A notice your server does not answer with a 2xx is tried again for about a day.

To give a token up yourself, when a seller removes your app on your side:

```bash theme={null}
curl https://api.shoply.gg/v1/oauth/revoke \
  -u "shoply_app_...:shoply_cs_..." \
  -d token=shoply_at_...
```

It answers 200 whether or not there was such a token. Deleting an app disconnects it from every store.

## With the SDKs

The server SDKs take a token as their key, and have the four steps:

```js Node.js theme={null}
import Shoply, { oauth } from "@shoplygg/node";

// 1. Where to send the seller. Keep the state and the verifier with their session.
const state = oauth.state();
const pkce = await oauth.pkce();
const url = oauth.authorizeUrl({ clientId, redirectUri, state, codeChallenge: pkce.challenge });

// 3. Back at your redirect URL, once the state checks out.
const token = await oauth.exchange({ clientId, clientSecret, code, redirectUri, codeVerifier: pkce.verifier });

// 4. A key of their store.
const shoply = new Shoply({ apiKey: token.accessToken, storeId: token.storeId });
```

```php PHP theme={null}
use Shoply\Client;
use Shoply\OAuth;

$state = OAuth::state();
$pkce  = OAuth::pkce();
$url   = OAuth::authorizeUrl(['clientId' => $clientId, 'redirectUri' => $redirectUri, 'state' => $state, 'codeChallenge' => $pkce['challenge']]);

$token  = OAuth::exchange(['clientId' => $clientId, 'clientSecret' => $clientSecret, 'code' => $code, 'redirectUri' => $redirectUri, 'codeVerifier' => $pkce['verifier']]);
$shoply = new Client(['apiKey' => $token['accessToken'], 'storeId' => $token['storeId']]);
```

```python Python theme={null}
import shoply

state = shoply.oauth.state()
pkce = shoply.oauth.pkce()
url = shoply.oauth.authorize_url(client_id, redirect_uri, state=state, code_challenge=pkce["challenge"])

token = shoply.oauth.exchange(client_id=client_id, client_secret=client_secret, code=code, redirect_uri=redirect_uri, code_verifier=pkce["verifier"])
client = shoply.Client(token["access_token"], store_id=token["store_id"])
```

A refusal from the token address is the SDK's error, with OAuth's word as its code: `invalid_grant`, `invalid_client`.

## Limits

| What | How many |
| - | - |
| Apps an account makes | 10 |
| Redirect URLs an app has | 10, each up to 500 characters |
| How long a code waits to be swapped | 10 minutes, once |
| Codes swapped and tokens given up, from one address | 300 in 10 minutes |
| Approval pages opened, approved or cancelled, for one account | 60 in 10 minutes |
| Apps disconnected from one store | 30 in 10 minutes |
| Apps connected to a store | No limit. They are not among the store's 15 API keys. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.