ShopEazydevelopers
Authentication

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.

WhatMethod and path
Server metadata (RFC 8414)GET /.well-known/oauth-authorization-server
Dynamic client registration (RFC 7591)POST /oauth/register
AuthorizationGET /oauth/authorize, in the merchant's browser
TokenPOST /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/register with client_name, redirect_uris and optionally logo_uri. You get a client_id. Only token_endpoint_auth_method: "none" and the authorization_code and refresh_token grants are accepted.
  • A client metadata document. Use an https URL as your client_id. The document at that URL must include client_id (equal to the URL) and redirect_uris, and may include client_name and logo_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

  1. Authorize. Send the merchant's browser to /oauth/authorize with response_type=code, client_id, redirect_uri, code_challenge, code_challenge_method=S256, resource=https://api.shopeazy.co, a scope and a state. Only S256 is accepted.
  2. Consent. The merchant signs in or signs up, picks the business and stores to connect, chooses what to allow, and selects Allow or Cancel.
  3. Code. The browser returns to your redirect_uri with code, state and iss. A cancel returns error=access_denied. The code lasts 60 seconds and works once.
  4. Token. POST /oauth/token with grant_type=authorization_code, client_id, code, redirect_uri, your code_verifier and the same resource.
  5. Call the API with the access token.

The code_verifier is 43 to 128 characters from A-Z a-z 0-9 - . _ ~.

Tokens

TokenLastsNotes
Authorization code60 secondsSingle use. Bound to your PKCE challenge and redirect URI
Access token1 hourStarts with sea_. Opaque, not a JWT
Refresh token60 daysStarts 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": "..." }.

ErrorWhen
invalid_requestA required parameter is missing
invalid_grantThe 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_clientclient_id is missing, or its metadata document could not be read
unsupported_grant_typeUse authorization_code or refresh_token
invalid_redirect_uri, invalid_client_metadataRegistration was refused
temporarily_unavailableNew 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.

ScopeWhat the merchant seesOpt-in
store:readSee your shop's details, setup and plan
listings:readSee your listings
listings:writeAdd and change your listings
orders:readSee orders, deliveries and bookings
orders:writeHandle orders
delivery:writeBook couriers and manage deliveriesYes
bookings:writeManage bookings
orders:refundRefund ordersYes
customers:readSee your customers
customers:writeAdd and change your customers
messages:readRead your customer messages
messages:sendMessage your customers
marketing:readSee your discounts and campaigns
marketing:writeRun discounts, reviews and social posts
design:writeDesign your shop
settings:writeChange shop settings
ai:spendUse your ShopEazy creditsYes
billing:writePay for your plan, add-ons and credits with your saved cardYes
team:writeInvite 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 forbidden with "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, or 403 subscription_locked for 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.

ApprovalFieldEffect
Monthly AI creditsaiCreditsPerMonthThe 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 capcourierMaxCentsA 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 requestsconfirmBookingRequestsThe 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.

On this page