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"
}
- Store
client_idandclient_secret. The secret is shown once. Register once per app, not per user — registrations are rate limited per IP. token_endpoint_auth_method:none(default) — public client: native apps, CLIs, single-page apps, AI chat clients. No secret is issued; PKCE is your proof.client_secret_basicorclient_secret_post— confidential client: server-side apps that can keep a secret. You get aclient_secretand must present it at the token endpoint. Prefer this whenever you have a backend.
redirect_urismust behttps://. Plainhttp://is accepted only for loopback (localhost,127.0.0.1,[::1]) so CLIs can listen locally. Fragments are not allowed. Matching is exact, including the path and query string.client_name,client_uriandlogo_uriare shown to the user on the consent screen. Use your real product name and website: the screen tells users to check that the app and address match where they started.contacts— email addresses of the people responsible for the app. Required (at least one) for confidential clients; registration without it returnsinvalid_client_metadata. Merchable uses these to reach you about your integration, for example before suspending it. Optional for public clients.tos_uriandpolicy_uri— optional https links to your terms of service and privacy policy. When supplied they are linked from the consent screen so users can read them before approving.
2. Send the user to the consent screen
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:
- We email the user, branded with your
client_nameandlogo_uri: "Your app has set up your Merchable account — set a password to finish connecting." - Setting the password proves they own the email. They log in and land on the consent screen for your request.
- They approve, and we redirect to your
redirect_uriwithcodeand yourstate. From here it is the normal flow: exchange the code (step 3), refresh, revoke. If they deny, you geterror=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
- One registration per app; secret stored server-side.
- Fresh
code_verifierandstateper authorization; verifystate. - Always send
scope; request only what the integration needs. - Exact
redirect_urimatch,https://only. - Store the rotated refresh token before using it; never refresh concurrently.
- On
401: refresh once, then re-authorize. - Handle
invalid_scope/invalid_targeterrors on the callback — they mean your authorize URL is wrong, not that the user declined.
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.