Merchant OAuth
Authorization code with PKCE, dynamic client registration, scopes, refresh and limits.
Beta
The Merchant API is in Beta. Connections are opened and paused by ShopEazy.
Apps that work for a merchant connect through OAuth 2.1: the authorization code flow with PKCE (S256), for public clients. There are no client secrets. The ShopEazy console is the authorization server.
For a worked example with curl, read Quickstart: Merchant.
Endpoints
The authorization server is https://app.shopeazy.co.
| What | Method and path |
|---|---|
| Server metadata (RFC 8414) | GET /.well-known/oauth-authorization-server |
| Dynamic client registration (RFC 7591) | POST /oauth/register |
| Authorization | GET /oauth/authorize, in the merchant's browser |
| Token | POST /oauth/token |
| Revocation (RFC 7009) | POST /oauth/revoke |
The API itself is at https://api.shopeazy.co/v1/merchant. Send the access token as Authorization: Bearer <access_token>.
Resource and discovery
A token works on one server only. Ask for the Merchant API by sending resource=https://api.shopeazy.co on both the authorize request and the token request. Without it, you get a token for the console itself, and the API answers 401 invalid_token. Any other resource is refused with invalid_target.
You can find the authorization server from the API alone. GET https://api.shopeazy.co/.well-known/oauth-protected-resource (RFC 9728) answers with the API's resource, its authorization_servers (the console), the scopes_supported and bearer_methods_supported. It is public and needs no token. Then read the server metadata at /.well-known/oauth-authorization-server on that server.
A call with no token, or with a token the API refuses, answers 401 with a WWW-Authenticate header. It points to the same metadata, for example Bearer resource_metadata="https://api.shopeazy.co/.well-known/oauth-protected-resource". When a token was sent, the header also carries error="invalid_token" and an error_description. The JSON error body is the same as for every other error.
The token and revocation endpoints take form-encoded bodies, and accept JSON too.
Register a client
Two ways to identify your app:
- Dynamic registration.
POST /oauth/registerwithclient_name,redirect_urisand optionallylogo_uri. You get aclient_id. Onlytoken_endpoint_auth_method: "none"and theauthorization_codeandrefresh_tokengrants are accepted. - A client metadata document. Use an
httpsURL as yourclient_id. The document at that URL must includeclient_id(equal to the URL) andredirect_uris, and may includeclient_nameandlogo_uri. ShopEazy fetches it from public addresses only, reads at most 64 KB, and caches it for a day.
Redirect URIs must be https, or http on localhost, 127.0.0.1 or [::1]. A client has 1 to 10 of them. Anyone can register any name, so the consent screen also shows the merchant the host your app sends them back to.
The flow
- Authorize. Send the merchant's browser to
/oauth/authorizewithresponse_type=code,client_id,redirect_uri,code_challenge,code_challenge_method=S256,resource=https://api.shopeazy.co, ascopeand astate. OnlyS256is accepted. - Consent. The merchant signs in or signs up, picks the business and stores to connect, chooses what to allow, and selects Allow or Cancel.
- Code. The browser returns to your
redirect_uriwithcode,stateandiss. A cancel returnserror=access_denied. The code lasts 60 seconds and works once. - Token.
POST /oauth/tokenwithgrant_type=authorization_code,client_id,code,redirect_uri, yourcode_verifierand the sameresource. - Call the API with the access token.
The code_verifier is 43 to 128 characters from A-Z a-z 0-9 - . _ ~.
Tokens
| Token | Lasts | Notes |
|---|---|---|
| Authorization code | 60 seconds | Single use. Bound to your PKCE challenge and redirect URI |
| Access token | 1 hour | Starts with sea_. Opaque, not a JWT |
| Refresh token | 60 days | Starts with ser_. Single use: each refresh returns a new pair |
The token response is { access_token, token_type: "Bearer", expires_in: 3600, refresh_token, scope }. scope is what the merchant allowed, space separated.
Refresh with grant_type=refresh_token, client_id and refresh_token. The new pair works for the same resource as the old one. If a refresh token that was already used is presented again, ShopEazy treats it as leaked and ends the whole connection, and the merchant must connect again. Always store the newest refresh token before you use the new access token.
Revoking any token with POST /oauth/revoke ends the whole connection. It answers 200 whether or not it knew the token.
Token endpoint errors
Errors are standard OAuth bodies: { "error": "...", "error_description": "..." }.
| Error | When |
|---|---|
invalid_request | A required parameter is missing |
invalid_grant | The code or refresh token is invalid, expired or already used, the redirect URI, PKCE verifier or resource does not match, or the connection was removed |
invalid_client | client_id is missing, or its metadata document could not be read |
unsupported_grant_type | Use authorization_code or refresh_token |
invalid_redirect_uri, invalid_client_metadata | Registration was refused |
temporarily_unavailable | New AI connections are paused |
Scopes
Ask for the least you need. The merchant can only narrow your request, never widen it. If you send no scope, the consent screen can offer anything the person's role holds. Scope names are listed in the scope parameter, space separated.
| Scope | What the merchant sees | Opt-in |
|---|---|---|
store:read | See your shop's details, setup and plan | |
listings:read | See your listings | |
listings:write | Add and change your listings | |
orders:read | See orders, deliveries and bookings | |
orders:write | Handle orders | |
delivery:write | Book couriers and manage deliveries | Yes |
bookings:write | Manage bookings | |
orders:refund | Refund orders | Yes |
customers:read | See your customers | |
customers:write | Add and change your customers | |
messages:read | Read your customer messages | |
messages:send | Message your customers | |
marketing:read | See your discounts and campaigns | |
marketing:write | Run discounts, reviews and social posts | |
design:write | Design your shop | |
settings:write | Change shop settings | |
ai:spend | Use your ShopEazy credits | Yes |
billing:write | Pay for your plan, add-ons and credits with your saved card | Yes |
team:write | Invite and manage your team |
Opt-in scopes move or spend money. They are off by default, are never part of a preset, and the merchant switches each one on separately. orders:refund, billing:write, ai:spend and team:write can only be given to an app by a business owner or admin.
Write implies read for two read scopes: a grant holding marketing:write passes a marketing:read check, and messages:send passes messages:read.
There is deliberately no scope for payouts, payment-provider keys, closing or deleting a business, buying a domain or accepting the merchant agreement. Adding power later means a new scope and a new consent, so a connection never quietly gains it.
Effective power
What a token can do on a call is the intersection of three things, checked on every call:
scopes granted and the person's role today and the plan
- If the scope was not granted, the call is
403 forbiddenwith "This connection was not given" and the scope. - If the person's role no longer allows it, for example they were demoted, the call is
403 forbidden. - If the plan does not include the operation, it is
403 plan_limit, or403 subscription_lockedfor a write while the plan is not active.
A store the person cannot reach, or that the connection was not given, is 404 store_not_found, never 403. Membership is re-read on every request and cached for at most 30 seconds.
GET /v1/merchant/session returns a grant block, only for an OAuth connection, with the scopes, the storeIds and any standingApprovals. Read it to skip a call the token cannot make. storeIds: null means every store the person can reach.
Standing approvals and the connection credit limit
The merchant can give a connection standing approvals, in the console under Settings, then AI apps. Your app never sets them.
| Approval | Field | Effect |
|---|---|---|
| Monthly AI credits | aiCreditsPerMonth | The most credits this connection may spend in a calendar month (UTC). The merchant can set up to 10 000. The consent screen starts at 100 |
| Courier cap | courierMaxCents | A courier booking costing no more than this, per order, goes ahead without asking. The merchant can set up to R5 000. Over it, bookCollection answers 403 over_courier_limit |
| Booking requests | confirmBookingRequests | The app may confirm booking requests without asking. Declines still ask |
A connection with no credit limit set is held only by the business's balance of credits.
These operations spend credits, so they are held to the monthly limit: draftProduct, generateProductCopy, generateImage, designForMe, buildPageWithAi, startStudioMake and sendCampaign. A call that would take the connection past its limit answers 403 over_credit_limit and spends nothing. The check and the spend are one transaction, so two calls at the same moment cannot both pass. The ShopEazy app's own sign-in has no connection and no such limit.
Changes the merchant makes
- Edit permissions. When the merchant changes what a connection may do, its access tokens stop working. Your next call answers
401 invalid_token. Refresh to get a token with exactly the new grant. - Disconnect. The merchant disconnects an app in Settings, then AI apps. Every token stops working at once.
Operations only the ShopEazy app can do
A small set of operations accepts only the ShopEazy app's own sign-in. They cover things like the bank details shoppers pay into, payment-provider and courier credentials, the team's roles, closing an account, and Storefront API keys. An OAuth token that calls one gets 403 forbidden, whatever scopes it holds. They are not part of the connected-app surface and are not documented here.