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

# Authorize a user (OAuth)

> Let an Vantr merchant grant your app delegated v2 access with the OAuth 2.0 authorization code flow and PKCE.

# Authorize a user (OAuth)

Use the **authorization code flow with PKCE** when your app acts on behalf of an Vantr merchant — for example, a third-party app that an account owner connects to their store. The merchant signs in on Vantr, approves the scopes you request, and your app receives a tenant-bound access token. You never see the merchant's password, and each merchant authorizes you separately.

<Info>
  If you are calling your **own** Vantr account from a backend you control, you do not need this flow. Use [client credentials](/authentication) instead — it is a single request with no browser redirect.
</Info>

## Which flow do I need?

<Columns cols={2}>
  <Card title="Authorization code + PKCE" icon="users" type="check">
    A third party connects **other merchants'** Vantr accounts to your app. Requires a browser redirect and a per-merchant consent screen. **This guide.**
  </Card>

  <Card title="Client credentials" icon="server" type="info">
    You call **your own** account from a trusted server. No browser, no user. See [Authentication](/authentication).
  </Card>
</Columns>

## How the flow works

<Steps titleSize="h3">
  <Step title="You redirect the merchant to Vantr">
    With your `client_id`, requested `scope`, and a PKCE challenge.
  </Step>

  <Step title="The merchant signs in and approves">
    Vantr shows a consent screen listing exactly the scopes you asked for.
  </Step>

  <Step title="Vantr redirects back with a one-time code">
    To your registered `redirect_uri`, with `?code=…&state=…`.
  </Step>

  <Step title="You exchange the code for tokens">
    Server-side, at `/oauth/token`, proving you started the request with your PKCE verifier.
  </Step>

  <Step title="You call the v2 API with the access token">
    The token is bound to that merchant's tenant. Refresh it when it expires.
  </Step>
</Steps>

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
sequenceDiagram
    participant U as Merchant (browser)
    participant A as Your app
    participant IE as Vantr
    A->>U: 1. Redirect to /oauth/authorize (client_id, scope, code_challenge, state)
    U->>IE: 2. Sign in and approve scopes
    IE->>U: 3. Redirect to your redirect_uri?code=…&state=…
    U->>A: 4. Browser hits your callback
    A->>IE: 5. POST /oauth/token (code, code_verifier, client auth)
    IE->>A: 6. access_token + refresh_token
    A->>IE: 7. GET /v2/... with Authorization: Bearer access_token
```

## Before you start

<Steps titleSize="h3">
  <Step title="Register a developer application" icon="square-plus">
    Create an app in the [developer portal](https://api.vantr.ai/developer). Add **every** redirect URI you will use under **Redirect URIs** — the callback must match one of them **exactly** (scheme, host, port, path, and trailing slash). Enable the `authorization_code` grant type and select the scopes your app may request.
  </Step>

  <Step title="Decide: confidential or public client" icon="git-branch">
    A **confidential** client runs entirely on a server and can keep a `client_secret`. A **public** client (single-page app, mobile, desktop, CLI) cannot. PKCE is required either way; the difference is only how you authenticate at the token endpoint (see [Step 4](#step-4-exchange-the-code-for-tokens)).
  </Step>
</Steps>

<Warning>
  Always run the **token exchange** server-side and keep the `client_secret` there. The browser only ever sees the authorize redirect and the `code` — never the secret or the access token, for a confidential client.
</Warning>

## Step 1 — Generate a PKCE verifier and challenge

For each authorization attempt, generate a fresh random **code verifier** (43–128 characters from `A–Z a–z 0–9 - . _ ~`) and derive its **challenge** as `BASE64URL(SHA256(verifier))`. Store the verifier in the user's session — you will need it in Step 4.

<CodeGroup>
  ```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import crypto from 'node:crypto';

  const base64url = (buf) => buf.toString('base64url'); // no padding

  const codeVerifier = base64url(crypto.randomBytes(32)); // 43 chars
  const codeChallenge = base64url(
    crypto.createHash('sha256').update(codeVerifier).digest()
  );
  const state = base64url(crypto.randomBytes(16)); // CSRF token

  // Persist { codeVerifier, state } in the user's session for the callback.
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import base64, hashlib, secrets

  def b64url(raw: bytes) -> str:
      return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

  code_verifier = b64url(secrets.token_bytes(32))     # 43 chars
  code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())
  state = b64url(secrets.token_bytes(16))             # CSRF token

  # Persist { code_verifier, state } in the user's session for the callback.
  ```
</CodeGroup>

<Note>
  Only `S256` is supported. A `plain` challenge or a verifier shorter than 43 characters is rejected with `invalid_grant`.
</Note>

## Step 2 — Send the merchant to the authorize URL

Redirect the user's browser to `https://api.vantr.ai/oauth/authorize` with the query parameters below. Vantr forwards the merchant to its consent screen (they sign in if needed).

<ParamField query="response_type" type="string" required>
  Must be `code`.
</ParamField>

<ParamField query="client_id" type="string" required>
  Your application's client ID.
</ParamField>

<ParamField query="redirect_uri" type="string" required>
  Must exactly match a redirect URI registered on your application.
</ParamField>

<ParamField query="scope" type="string" required>
  Space-separated scopes, e.g. `products.read categories.read`. Request only what you need — see [Scopes](/guides/scopes).
</ParamField>

<ParamField query="code_challenge" type="string" required>
  The PKCE challenge from Step 1.
</ParamField>

<ParamField query="code_challenge_method" type="string" required>
  Must be `S256`.
</ParamField>

<ParamField query="state" type="string">
  Strongly recommended. An opaque value you generate and verify on return to prevent CSRF. Vantr echoes it back unchanged.
</ParamField>

<CodeGroup>
  ```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const authorizeUrl = new URL('https://api.vantr.ai/oauth/authorize');
  authorizeUrl.search = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.VANTR_CLIENT_ID,
    redirect_uri: 'https://app.example.com/callback/invoice-edge',
    scope: 'products.read categories.read vendors.read locations.read',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
  }).toString();

  res.redirect(authorizeUrl.toString());
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  from urllib.parse import urlencode

  params = {
      "response_type": "code",
      "client_id": os.environ["VANTR_CLIENT_ID"],
      "redirect_uri": "https://app.example.com/callback/invoice-edge",
      "scope": "products.read categories.read vendors.read locations.read",
      "code_challenge": code_challenge,
      "code_challenge_method": "S256",
      "state": state,
  }
  authorize_url = "https://api.vantr.ai/oauth/authorize?" + urlencode(params)
  # return a 302 redirect to authorize_url
  ```
</CodeGroup>

<Note>
  The merchant approving the consent screen must have permission for the scopes you request. If their role cannot grant a scope, the request is denied — request the narrowest scope set that does the job.
</Note>

## Step 3 — Handle the redirect back

After the merchant approves, Vantr redirects the browser to your `redirect_uri` with a one-time `code` and your `state`. **Always verify `state`** against the value you stored, then continue server-side.

```text Approved theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://app.example.com/callback/invoice-edge?code=ieo_code_xxx&state=abc123
```

If the merchant declines (or cannot grant a scope), you receive an error instead of a code:

```text Denied theme={"theme":{"light":"github-light","dark":"github-dark"}}
https://app.example.com/callback/invoice-edge?error=access_denied&state=abc123
```

```javascript Node.js callback theme={"theme":{"light":"github-light","dark":"github-dark"}}
app.get('/callback/invoice-edge', async (req, res) => {
  const { code, state, error } = req.query;
  const session = req.session; // however you stored Step 1

  if (error) return res.status(400).send(`Authorization failed: ${error}`);
  if (!state || state !== session.state) {
    return res.status(400).send('State mismatch — possible CSRF.');
  }
  const tokens = await exchangeCode(code, session.codeVerifier); // Step 4
  // store tokens for this merchant, then redirect into your app
});
```

<Warning>
  The authorization code is **single-use** and expires **5 minutes** after it is issued. Exchange it immediately and never log it.
</Warning>

## Step 4 — Exchange the code for tokens

`POST` to `https://api.vantr.ai/oauth/token` with `Content-Type: application/x-www-form-urlencoded`.

<ParamField body="grant_type" type="string" required>
  `authorization_code`.
</ParamField>

<ParamField body="code" type="string" required>
  The code from Step 3.
</ParamField>

<ParamField body="redirect_uri" type="string" required>
  The same `redirect_uri` you used in Step 2.
</ParamField>

<ParamField body="code_verifier" type="string" required>
  The PKCE verifier from Step 1 that matches the challenge you sent.
</ParamField>

How you authenticate the client depends on its type:

<Tabs>
  <Tab title="Confidential client" icon="server">
    Send your credentials with HTTP Basic auth (`client_id` as username, `client_secret` as password).

    ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl -u "$CLIENT_ID:$CLIENT_SECRET" \
      -d "grant_type=authorization_code" \
      -d "code=ieo_code_xxx" \
      -d "redirect_uri=https://app.example.com/callback/invoice-edge" \
      -d "code_verifier=$CODE_VERIFIER" \
      "https://api.vantr.ai/oauth/token"
    ```

    ```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}}
    async function exchangeCode(code, codeVerifier) {
      const basic = Buffer
        .from(`${process.env.VANTR_CLIENT_ID}:${process.env.VANTR_CLIENT_SECRET}`)
        .toString('base64');

      const res = await fetch('https://api.vantr.ai/oauth/token', {
        method: 'POST',
        headers: {
          Authorization: `Basic ${basic}`,
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: new URLSearchParams({
          grant_type: 'authorization_code',
          code,
          redirect_uri: 'https://app.example.com/callback/invoice-edge',
          code_verifier: codeVerifier,
        }),
      });
      if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${await res.text()}`);
      return res.json();
    }
    ```
  </Tab>

  <Tab title="Public client" icon="smartphone">
    A public client has no secret. Send `client_id` in the body and rely on PKCE to prove the request.

    ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
    curl \
      -d "grant_type=authorization_code" \
      -d "client_id=$CLIENT_ID" \
      -d "code=ieo_code_xxx" \
      -d "redirect_uri=https://app.example.com/callback/invoice-edge" \
      -d "code_verifier=$CODE_VERIFIER" \
      "https://api.vantr.ai/oauth/token"
    ```

    <Note>
      Mark the application as a public client when you create it. Confidential clients are required to use Basic auth and cannot fall back to body `client_id`.
    </Note>
  </Tab>
</Tabs>

A successful response returns the token set:

```json Response theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "access_token": "ieo_at_xxx",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "products.read categories.read vendors.read locations.read",
  "refresh_token": "ieo_rt_xxx",
  "token_id": "…",
  "expires_at": "2026-06-13T19:20:00.000Z"
}
```

<ResponseField name="access_token" type="string" required>
  Opaque bearer token for v2 requests. Lives **1 hour** by default.
</ResponseField>

<ResponseField name="refresh_token" type="string">
  Use it to mint a new access token without sending the merchant through consent again. Lives **90 days**. Omitted if the application has refresh tokens disabled.
</ResponseField>

<ResponseField name="scope" type="string" required>
  The scopes actually granted. May be narrower than you requested.
</ResponseField>

<Tip>
  Store the `refresh_token` (and the granted `scope`) per merchant in encrypted, server-side storage, keyed by tenant. Treat it like a password.
</Tip>

## Step 5 — Call the v2 API

Send the access token as a Bearer credential. The token already identifies the merchant's tenant — you never pass a tenant ID yourself.

```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
  "https://api.vantr.ai/v2/products?limit=25"
```

```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}}
const res = await fetch('https://api.vantr.ai/v2/products?limit=25', {
  headers: { Authorization: `Bearer ${accessToken}` },
});
```

<Check>
  A `200` here confirms the full chain works: PKCE, consent, tenant binding, and scope enforcement.
</Check>

## Step 6 — Refresh the access token

When the access token expires (after \~1 hour), exchange the refresh token for a new one — same endpoint, `grant_type=refresh_token`. Use the same client authentication as Step 4.

```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=ieo_rt_xxx" \
  "https://api.vantr.ai/oauth/token"
```

You may pass `scope` to **narrow** the new token, but never to widen it beyond the original grant.

<Warning>
  **Refresh tokens rotate.** Each refresh revokes the token you just used and returns a **new** `refresh_token` — persist the new one and discard the old. Replaying a spent refresh token fails with `invalid_grant`; treat that as a signal the token may have leaked and re-authorize the merchant.
</Warning>

## Token lifetimes

| Credential         | Lifetime  | Reuse                                 |
| ------------------ | --------- | ------------------------------------- |
| Authorization code | 5 minutes | Single use                            |
| Access token       | 1 hour    | Reusable until expiry or revocation   |
| Refresh token      | 90 days   | Single use — rotates on every refresh |

## Revoke and inspect

| Action                                    | Endpoint                                      | Auth                        |
| ----------------------------------------- | --------------------------------------------- | --------------------------- |
| Revoke an access or refresh token         | `POST /oauth/revoke`                          | Basic (confidential client) |
| Check whether a token is active           | `POST /oauth/introspect`                      | Basic (confidential client) |
| Discover endpoints and supported features | `GET /.well-known/oauth-authorization-server` | None                        |

```bash Revoke theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "token=ieo_at_xxx" \
  "https://api.vantr.ai/oauth/revoke"
```

<Note>
  When a merchant disconnects your app, call `/oauth/revoke` so their tokens stop working immediately rather than waiting for expiry.
</Note>

## Troubleshooting

<AccordionGroup>
  <Accordion title="invalid_grant — PKCE verification failed" icon="key-round">
    The `code_verifier` you sent in Step 4 does not hash to the `code_challenge` you sent in Step 2. Make sure you store the verifier per attempt and that both sides use base64url **without padding**.
  </Accordion>

  <Accordion title="invalid_grant — redirect_uri does not match" icon="link-2-off">
    The `redirect_uri` in the token request must be byte-for-byte identical to the one in the authorize request, and both must be registered on the application. Watch for trailing slashes and `http` vs `https`.
  </Accordion>

  <Accordion title="invalid_grant — code already used or expired" icon="timer-off">
    Authorization codes are single-use and expire after 5 minutes. Do not retry an exchange with the same code; restart from Step 2.
  </Accordion>

  <Accordion title="invalid_client — 401 at the token endpoint" icon="user-x">
    A confidential client must use HTTP Basic auth, and the secret must not have been rotated. A public client must send `client_id` in the body and must be registered as public.
  </Accordion>

  <Accordion title="access_denied on the callback" icon="shield-x">
    The merchant declined, or their role cannot grant a scope you requested. Request fewer or narrower scopes, or have an account owner complete the connection.
  </Accordion>

  <Accordion title="403 from a v2 endpoint" icon="shield-alert">
    The token is valid but its granted scopes do not include one the endpoint requires. Check the scope on the endpoint's API reference page and re-authorize with it included.
  </Accordion>

  <Accordion title="slow_down / 429 from the token endpoint" icon="gauge">
    OAuth token calls are rate limited to 60 per minute per credential. Back off using the `Retry-After` header. v2 endpoints allow 240 per minute.
  </Accordion>
</AccordionGroup>

## Security checklist

<Columns cols={2}>
  <Card title="One verifier per attempt" icon="dice-5" horizontal>
    Generate a fresh `code_verifier` and `state` for every authorization and bind them to the user's session.
  </Card>

  <Card title="Verify state" icon="shield-check" horizontal>
    Reject the callback if `state` does not match what you stored.
  </Card>

  <Card title="Exchange server-side" icon="server-cog" horizontal>
    Never expose the `client_secret` or run the token exchange in the browser for a confidential client.
  </Card>

  <Card title="Encrypt refresh tokens" icon="lock" horizontal>
    Store refresh tokens encrypted, per tenant, and rotate the stored value on every refresh.
  </Card>
</Columns>

<Tip>
  Building a server-only integration with no merchant in the loop? Skip all of this and use [client credentials](/authentication).
</Tip>
