Merchable APIReferenceGetting startedAuthentication & scopesOAuth for third-party appsDesignsPlacing an orderSyncing customersWebhooksPagination, errors & rate limitsVersioning & changelog

OAuth for third-party apps

Use OAuth 2.1 when your app acts on behalf of other people's Merchable accounts — a marketplace connector, an ERP plugin, an AI agent, anything your customers install. (If you are integrating your own account, an API key from Settings → Developer is simpler; see Authentication & scopes.)

Merchable is the authorization server and the resource server. There is nothing to configure on our side: register your client, send users to the consent screen, exchange the code, call the API.

Issuer https://app.merchable.co
Discovery https://app.merchable.co/.well-known/oauth-authorization-server (RFC 8414)
API resource metadata https://api.merchable.co/.well-known/oauth-protected-resource (RFC 9728)
Grant Authorization code with PKCE (S256). No implicit, password or client-credentials grants.
Client types Public (PKCE only) or confidential (PKCE + client secret)
Registration Dynamic, open, rate-limited (RFC 7591)
Access token mcho_…, 1 hour
Refresh token mchr_…, 30-day sliding window, rotated on every use, reuse revokes the grant
Grant lifetime Public clients: 90 days from consent, then the user must re-approve. Confidential clients: unlimited while refreshed within the window.
Revocation POST /oauth/revoke (RFC 7009)

Every endpoint below is on https://app.merchable.co unless stated otherwise. All of them send permissive CORS headers, so browser-based clients work.

1. Register your client (once)

curl -X POST https://app.merchable.co/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Acme ERP Connector",
    "client_uri": "https://acme.example",
    "logo_uri": "https://acme.example/logo.png",
    "contacts": ["developers@acme.example"],
    "tos_uri": "https://acme.example/terms",
    "policy_uri": "https://acme.example/privacy",
    "redirect_uris": ["https://acme.example/oauth/merchable/callback"],
    "token_endpoint_auth_method": "client_secret_basic"
  }'
{
  "client_id": "4f1c2d7e-…",
  "client_secret": "mchs_…",
  "client_secret_expires_at": 0,
  "client_id_issued_at": 1790000000,
  "client_name": "Acme ERP Connector",
  "client_uri": "https://acme.example",
  "contacts": ["developers@acme.example"],
  "tos_uri": "https://acme.example/terms",
  "policy_uri": "https://acme.example/privacy",
  "redirect_uris": ["https://acme.example/oauth/merchable/callback"],
  "token_endpoint_auth_method": "client_secret_basic",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "account:read designs:read shops:read orders:read orders:write mcp"
}

Generate a PKCE pair per authorization:

import base64, hashlib, secrets
verifier  = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()  # 43 chars
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()

Then redirect the user's browser to:

https://app.merchable.co/oauth/authorize
  ?response_type=code
  &client_id=4f1c2d7e-…
  &redirect_uri=https%3A%2F%2Facme.example%2Foauth%2Fmerchable%2Fcallback
  &scope=account%3Aread%20designs%3Aread%20orders%3Awrite
  &state=…random, bound to the user's session…
  &code_challenge=…
  &code_challenge_method=S256
  &resource=https%3A%2F%2Fapi.merchable.co
Parameter
response_type Always code.
scope Space-separated. Always send it. If omitted the grant defaults to mcp (full access) and the consent screen says so — fine for AI chat clients, wrong for an API integration. Choose the smallest set that works (see Authentication & scopes); unknown scopes are rejected with invalid_scope.
state Recommended. Echoed back unchanged on the redirect; verify it.
code_challenge, code_challenge_method Required. Only S256. Verifier length 43–128.
resource Optional (RFC 8707). If sent, must be https://api.merchable.co (or …/v1) for the Account API, or https://app.merchable.co/mcp for the MCP server. One grant works against both.

The user logs in to Merchable if needed, sees your app's name and website, the account it will be connected to, and the exact scopes you requested, then approves or denies. We redirect back to your redirect_uri:

https://acme.example/oauth/merchable/callback?code=mchc_…&state=…

On denial or a validation error you get ?error=…&error_description=…&state=… instead (access_denied, invalid_scope, invalid_target, unsupported_response_type, invalid_request). An unknown client_id or an unregistered redirect_uri never redirects — the user sees an error page.

Codes are single-use and expire after 10 minutes.

3. Exchange the code for tokens

Confidential client (client_secret_basic):

curl -X POST https://app.merchable.co/oauth/token \
  -u "4f1c2d7e-…:mchs_…" \
  -d grant_type=authorization_code \
  -d code=mchc_… \
  -d redirect_uri=https://acme.example/oauth/merchable/callback \
  -d code_verifier=…

Public client (no secret):

curl -X POST https://app.merchable.co/oauth/token \
  -d grant_type=authorization_code \
  -d client_id=4f1c2d7e-… \
  -d code=mchc_… \
  -d redirect_uri=https://acme.example/oauth/merchable/callback \
  -d code_verifier=…

Form-encoded or JSON bodies are both accepted. client_secret_post clients send client_secret in the body instead of the Authorization header.

{
  "access_token": "mcho_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "mchr_…",
  "scope": "account:read designs:read orders:write"
}

Errors are RFC 6749 JSON:

Status error Meaning
401 invalid_client Unknown client_id, or a confidential client's secret is missing or wrong. The code is not consumed.
400 invalid_grant Bad, expired, reused or foreign code; redirect_uri mismatch; PKCE verifier mismatch. Deliberately no further detail.
400 unsupported_grant_type Only authorization_code and refresh_token.

4. Call the API

curl https://api.merchable.co/v1/account \
  -H "Authorization: Bearer mcho_…"

The token resolves to the account the user approved. Everything in the reference works exactly as with an API key; an operation outside the granted scopes returns 403 with the missing scope in detail.

A 401 means the token is expired, revoked or unknown. The response carries a WWW-Authenticate: Bearer error="invalid_token" … challenge. Refresh (below) on expired; restart the flow if refreshing fails.

The same token also authenticates the MCP server at https://app.merchable.co/mcp; MCP tools enforce the same scopes.

5. Refresh

Access tokens live one hour. Refresh before (or when) they expire:

curl -X POST https://app.merchable.co/oauth/token \
  -u "4f1c2d7e-…:mchs_…" \
  -d grant_type=refresh_token \
  -d refresh_token=mchr_…

You get a new access token and a new refresh token. Both old tokens are dead the moment this call succeeds, including the previous access token even if it had time left. Persist the new pair atomically before making further requests, and do not refresh while other requests using the old access token are still in flight — they will start returning 401. Refreshing just before expiry, with requests briefly paused, is the safe pattern.

Reuse detection. If a refresh token that has already been rotated is presented again, we treat it as leaked and revoke the entire grant — the current access and refresh tokens stop working too, and the user must re-authorize. In practice this only bites if you have two processes sharing one refresh token, or you lost the response to a refresh call. Serialise refreshes per grant and store the response before using it.

Refresh tokens expire 30 days after they were issued if unused. Each refresh starts a new 30-day window, so an actively used grant stays alive — with one exception:

Public clients have a 90-day grant lifetime. Because a public client has no secret, a leaked refresh token could otherwise be kept alive indefinitely, so every grant to a public client ends 90 days after the user approved it, no matter how often it is refreshed. Refreshing past that point returns invalid_grant; send the user through the consent screen again. The expires_in on the final tokens before the cap is shortened to match, and the user sees the date under Connected apps. Confidential clients have no cap: register with a client secret if your integration needs to run unattended for longer than 90 days.

6. Revoke

When the user disconnects your app, or you no longer need access:

curl -X POST https://app.merchable.co/oauth/revoke \
  -d token=mchr_…

Either the access or the refresh token may be passed; the whole grant is revoked. The response is always 200 with an empty body, even for an unknown token. No client authentication is required.

Users can also disconnect your app themselves at Settings → Developer → Connected apps in app.merchable.co. Expect 401s afterwards and send them back through the flow.

7. Keep your client up to date

Registration also returns a one-time registration_access_token (mchg_…) and a registration_client_uri. Store the token with your secret: it is the Bearer credential for the RFC 7592 client configuration endpoint, which lets you change what users see on the consent screen without re-registering.

Update just the logo:

curl -X PUT https://app.merchable.co/oauth/register/4f1c2d7e-… \
  -H "Authorization: Bearer mchg_…" \
  -H "Content-Type: application/json" \
  -d '{ "logo_uri": "https://acme.example/logo-v2.png" }'

Omitted fields keep their current values. You may change client_name, client_uri, logo_uri, contacts, tos_uri, policy_uri and redirect_uris; the auth method, secret and status cannot be changed here. GET the same URL to read your current metadata, and DELETE it to deprovision the client permanently (every connected account is cut off).

A lost registration token cannot be recovered. Register a new client and migrate users to it.

Client status and review

Registration is self-serve, but every new client is reviewed by Merchable. Each client has a status:

Status Effect
active Normal operation.
suspended Consent, token and API requests fail (invalid_client / 401) until Merchable reactivates the client. Existing grants are kept, so users do not need to reconnect afterwards. Your registration token still works, so you can fix metadata.
revoked Permanent. Every grant is revoked; users would have to connect a new client.

We suspend or revoke clients that impersonate other brands, request far broader scopes than their function needs, or are reported by users. Use your real product name and website, and request the smallest scope set that works.

Verified apps

Merchable can mark a client as verified after reviewing it. A verified app shows a "Verified by Merchable" badge on the consent screen instead of the unverified warning, its grants have no 90-day cap even if it registered as a public client, and its registration is never merged with another (see below). verified is returned in the registration and configuration responses. To request verification, contact us with your client_id once your integration is working end to end.

Duplicate public registrations are merged

AI chat clients register once per user with identical metadata. To keep that manageable, a public registration whose client_name and redirect_uris exactly match an existing active, unverified public client returns that client's existing client_id with "reused": true and no new registration_access_token. Confidential and verified clients are never reused. If you are a public app and want your own client, use a distinct client_name or your own callback URL — or register as confidential.

Creating accounts for your users

If your user has no Merchable account yet, a verified confidential client can create one for them and hand them straight into consent, so they connect in one pass: set a password → approve → back in your app. Without this, the user is sent to sign-up, finishes the onboarding wizard, and has to start the connection again from your app.

This is the only capability gated on verification: it creates records for email addresses you supply, so a person at Merchable reviews your app first. Public and unverified clients get 403 (client_not_verified); email support@merchable.co with your client_id to request verification.

curl -X POST https://api.merchable.co/v1/partner/accounts \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "user": { "email": "sam@example.com", "first_name": "Sam", "last_name": "Lee" },
    "account": { "business_name": "Lee Fitness", "country": "AU", "website": "https://samsgym.example" },
    "authorization": {
      "redirect_uri": "https://acme.example/oauth/merchable/callback",
      "state": "af0ifjsldkj",
      "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
      "code_challenge_method": "S256",
      "scope": "account:read designs:read orders:write"
    }
  }'

Authenticate with HTTP Basic, client_id:client_secret, exactly as at the token endpoint. authorization is what you would otherwise put on the authorize URL in step 2, validated the same way; keep the code_verifier and state as you would for a normal authorization. country (ISO 3166-1 alpha-2) sets the account's currency, language and timezone.

HTTP/1.1 202 Accepted

{
  "data": {
    "account_id": "5b0e…",
    "status": "pending_user",
    "expires_at": "2026-10-08T11:06:47Z"
  }
}

No token or code is returned. What happens next:

  1. We email the user, branded with your client_name and logo_uri: "Your app has set up your Merchable account — set a password to finish connecting."
  2. Setting the password proves they own the email. They log in and land on the consent screen for your request.
  3. They approve, and we redirect to your redirect_uri with code and your state. From here it is the normal flow: exchange the code (step 3), refresh, revoke. If they deny, you get error=access_denied.

The hand-off expires after 7 days. After that the account still exists, but you need to send the user through a normal authorize URL to connect.

Response Meaning
202 Account created; waiting for the user.
401 Missing or wrong client credentials.
403 client_not_verified Your client is not verified, or is public.
409 user_exists The email already has a Merchable account. The body's authorize_url is the normal consent URL built from your authorization; redirect the user there to sign in and approve.
422 validation Field errors keyed like authorization.redirect_uri or account.country.
429 More than 50 accounts in an hour for your client.
HTTP/1.1 409 Conflict

{
  "type": "https://api.merchable.co/docs/errors#user_exists",
  "title": "Conflict",
  "status": 409,
  "detail": "This email already has a Merchable account. …",
  "error": "user_exists",
  "authorize_url": "https://app.merchable.co/oauth/authorize?client_id=…&code_challenge=…&state=af0ifjsldkj"
}

The new account starts in onboarding: the owner finishes setup (region, branding, payment method) on their next visit to app.merchable.co. Until then GET /v1/account returns "setup_complete": false with the current registration_step, and orders need a payment method on file, so point the user to the web app when they are ready to order.

Checklist

Discovery, for libraries that support it

Point any RFC 8414-aware OAuth library at the issuer https://app.merchable.co; it will find the endpoints, supported scopes, S256 and the client authentication methods from the metadata document. The Account API's protected-resource document names the same issuer, so a client that starts from an API 401 challenge's resource_metadata ends up in the same place.