OAuth for Services and Integrations
21 minute read
Introduction
Most of our documentation assumes you authenticate with an API key — a long-lived credential that belongs to a person, either an operator admin or a seller user. That works, and it remains the fastest way to explore the API from a tool like Insomnia or Postman.
It is not the best fit for a service. A nightly product sync, an order-fulfilment worker or a middleware integration is not a person, and giving it a person’s credential creates problems that have nothing to do with the code you are writing.
Marketplacer is an OAuth 2.1 authorization server. An operator can register your service as an OAuth client, and your service can then obtain short-lived access tokens for the GraphQL API in its own right.
This playbook covers:
- which grant to use
- why OAuth suits a service better than an API key
- how a client gets registered
- a worked example using
private_key_jwt
It is written both for operators registering a client and for third-party implementors building against the Operator API.
Which grant do you need?
Answer this before you write any code, because it decides how much of the marketplace your tokens can reach.
| What your service does | Grant to use | The token acts as | What it can reach |
|---|---|---|---|
| Acts as itself — scheduled syncs, back-office jobs, no end user involved | client_credentials | Your service | The whole marketplace, limited by the scopes on your client |
| Acts on behalf of a signed-in seller or operator admin | authorization_code | The person who signed in and consented | Only that person’s own data, within their own permissions |
Most of the integrations described in our playbooks — moving product data, creating orders, reconciling refunds on a schedule — are the first row, and the rest of this page covers that case.
Serving many sellers? Use authorization_code
If you are building an application that many sellers sign up to — a channel manager, a listing tool, an analytics dashboard — do not use client_credentials. It would give your application access across the whole marketplace, and every action it took would be recorded as your service rather than as the seller who asked for it.
Use authorization_code instead, so that each seller signs in and consents, and the token you receive is confined to their own data. See Acting on behalf of a person below.
Why OAuth for a service
The credential is not a person
An API key belongs to an admin or a seller user. That person’s account has a lifecycle: they change roles, they leave the organization, their account gets disabled during offboarding. None of that has anything to do with your integration, but all of it can affect the credential your integration depends on.
An OAuth client is its own identity. It is created for the integration, it belongs to the integration, and it outlives whoever set it up.
Your audit trail tells the truth
Actions taken with a person’s API key are attributed to that person. Six months later the audit log says a particular admin adjusted two thousand prices overnight — which is not what happened, and is not useful when you are trying to reconstruct events.
A service authenticating as itself is recorded as itself.
Nothing secret needs to be handed over
This is the strongest argument for private_key_jwt, and the reason we recommend it.
A shared secret — an API key, or an OAuth client secret — has to be generated by us and then handed to you. It is shown once, it travels to you over some channel, and you have to keep it somewhere you can read it back, because you present it on every request. We store only a hash of it and cannot recover the original, so the plaintext cannot leak from our side — but the handover, and your own readable copy, are both real exposure points.
With private_key_jwt you generate a keypair and give us only the public key. Your private key never leaves your infrastructure. There is nothing on the Marketplacer side that can be stolen and used to impersonate your service.
Credentials stop travelling on every request
An API key is sent on every single API call. Every one of those requests is an opportunity for the credential to end up somewhere it should not be — a proxy log, an error report, a screenshot in a support ticket. And because the key is long-lived, a leak stays exploitable until somebody notices and rotates it.
With OAuth, your long-lived credential is used only at the token endpoint. Your API calls carry an access token that expires an hour after it was issued.
Rotation stops being an event
Rotating an API key means a coordinated cutover: create the new key, deploy it everywhere, verify, revoke the old one — with a window in which a mistake means downtime. Our API Key Rotation playbook exists because this needs planning.
Rotating a signing key with private_key_jwt is a publish. You add the new public key to your JWKS alongside the old one, start signing with the new key, and remove the old one once nothing is using it. Both keys are valid during the overlap, so there is no cutover moment and nothing to coordinate with us.
Tokens can be restricted to one API
An access token can be bound to the specific API it is meant for. Ask for a token addressed to the GraphQL API and that is the only place it works — if it leaks, it cannot be replayed against another API on the same host. An API key has no equivalent restriction.
It is a standard
This is ordinary OAuth 2.1. Your language almost certainly has a library for it, your secret manager probably has a pattern for it, and your API gateway and observability tooling likely already understand it. You can also read our endpoint configuration from a discovery document rather than hardcoding URLs.
Tokens can be revoked centrally
If something goes wrong, tokens can be revoked and the client can be disabled — without first working out which of a dozen API keys is the one being misused.
When an API key is still the right choice
OAuth is not a replacement for every API key. An API key remains the right tool when:
- You are exploring. Getting a first query working in Insomnia or Postman is faster with a key. Our Getting Started guide uses one for exactly this reason.
- A person is driving. A one-off data fix, an ad-hoc report, a script somebody runs by hand — the credential genuinely does belong to a person here.
- It already works. An existing integration running on an API key does not need to be migrated. Scoped and rotated properly, it is a perfectly reasonable production credential.
- You are a seller integrating your own business. OAuth clients are registered by operators, and a seller cannot register one. If you are a seller automating your own account — your own listings, your own orders — a seller API key is currently your main option.
Building something any seller could use?
The seller-side limit above is about who registers a client, not about who can use one.
If you are building a product that any seller on the marketplace could sign up to — a channel manager, a listing tool, a reporting dashboard — you can build it on the authorization_code grant, in cooperation with the operator. The operator registers a single OAuth client for your product. Each seller then signs in through Marketplacer and consents to the scopes you asked for, and you receive a token confined to that seller’s own data.
That is a better arrangement than asking every seller to generate an API key and paste it into your product: the seller authenticates themselves, you never handle their credentials, and each grant can be withdrawn independently. See Acting on behalf of a person for what the flow involves.
It does depend on the operator registering the client for you, so start that conversation early.
Our recommendation is narrower than “use OAuth”: if you are building a service, register a client.
How a client gets registered
Registering a client takes two parties. You cannot self-serve — the operator of the marketplace creates the client for you.
What you do first
- Generate an asymmetric keypair. Ideally your OAuth client library does this for you — many will also publish the JWKS and sign the assertions described below — so check what yours offers before hand-rolling any of it. Keep the private key wherever your service already keeps secrets.
- Publish the public key as a JWKS (JSON Web Key Set) at an HTTPS URL your service controls — this is your
jwks_uri. A URL is strongly preferred, because it is what makes key rotation self-service later. - If you genuinely cannot host a URL, prepare the JWKS as a JSON document and send that instead. It works, but every future key rotation then needs the operator to update your client by hand.
- Send the operator your
jwks_uri(or JWKS JSON), the scopes your integration needs, and a short description of what it does.
What the operator does
- In the operator portal, go to Configuration → OAuth Clients and create a client. This needs a Full Admin, or any role granted the Manage OAuth Clients permission.
- Choose the Backend API access scenario — the one for a server-to-server integration with no end user.
- Set the authentication method to
private_key_jwtand record thejwks_uri(or paste the JWKS JSON). - Tick the scopes the integration needs, and no more. Scopes work exactly as they do for API keys — see API Key Scopes for the resource catalogue and how to choose.
- Add the GraphQL API to the client’s allowed resources, so the integration can request a token restricted to it — see Restricting the token to GraphQL.
- Send the resulting
client_idback to you.
The client_id is not a secret. With private_key_jwt there is no secret to exchange at all, which means this handover does not need a secure channel.
We re-read your JWKS
When you register a jwks_uri we refetch it periodically, and we will also refetch on demand if you present an assertion signed with a key we have not seen before.
In practice this means you can add a new key to your JWKS and start signing with it without waiting for anything, and without telling us.
Choosing a client authentication method
Whatever grant you use, your service has to prove who it is when it talks to the token endpoint. There are two families.
private_key_jwt — recommended. You sign a short-lived JWT (a “client assertion”) with your private key, and we verify it against the public key in your JWKS. Nothing secret is ever handed over, so there is no shared value to transmit or to keep readable at your end, and rotation is a JWKS publish rather than a coordinated cutover.
Client secrets — the fallback. client_secret_basic and client_secret_post send a shared secret with the request. client_secret_jwt signs an assertion with the shared secret instead of transmitting it, which is better — though it is the one method where we have to keep a usable copy of your key rather than a hash of it, because verifying your HMAC requires the key itself. Choose one of these only if your environment genuinely cannot manage asymmetric keys — for example a platform with no access to a crypto library or to key storage.
The authentication method is fixed at creation
A client’s authentication method cannot be changed after the client is created. If you start with a client secret and later want private_key_jwt, the operator has to create a new client, and you migrate to the new client_id.
This is worth a moment’s thought up front rather than a migration later.
Endpoints
| Purpose | Endpoint |
|---|---|
| Token endpoint | POST https://<your-marketplace>/oauth/token |
| Discovery document | GET https://<your-marketplace>/.well-known/oauth-authorization-server |
| Our public keys | GET https://<your-marketplace>/.well-known/jwks.json |
Substitute your own marketplace hostname — it is the same host you send GraphQL queries to.
Read the discovery document at startup rather than hardcoding the token endpoint. It is the standard OAuth mechanism for this (RFC 8414), and most OAuth libraries can consume it directly.
Getting an access token
Two steps: build a signed assertion proving you are your client, then exchange that assertion for an access token.
The examples below use Node with jose to sign the assertion. Everything else is plain fetch, so this translates directly to any language with a JWT library.
Step 1 — Build the client assertion
The assertion is a short-lived JWT signed with your private key. Every claim below is checked, and a mistake in any of them produces an authentication failure rather than a partial success.
| Claim | Value |
|---|---|
iss | your client_id |
sub | your client_id — the same value as iss |
aud | the full token endpoint URL, for example https://your-marketplace.example/oauth/token |
iat | now |
exp | no more than 5 minutes after iat |
jti | a unique value for every assertion |
Supported signing algorithms are ES256, ES384, ES512, RS256, RS384, RS512, PS256, PS384 and PS512. Include a kid header identifying which key in your JWKS you signed with.
A new jti for every request
Each jti may be used once. We record it, and we reject any assertion that reuses one.
This is the most common mistake with this flow. It is tempting to build one assertion and cache it, and it will work exactly once. Generate a fresh assertion — with a fresh jti — for every token request. Assertions are cheap; the access token is the thing worth caching.
import { SignJWT, importPKCS8 } from 'jose'
import { randomUUID } from 'node:crypto'
const CLIENT_ID = process.env.MARKETPLACER_CLIENT_ID
const TOKEN_ENDPOINT = 'https://your-marketplace.example/oauth/token'
// Your private key in PKCS#8 PEM form, from wherever you keep secrets.
const privateKey = await importPKCS8(process.env.MARKETPLACER_PRIVATE_KEY, 'ES256')
async function buildClientAssertion() {
return new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: 'my-2026-key' })
.setIssuer(CLIENT_ID) // iss
.setSubject(CLIENT_ID) // sub — must equal iss
.setAudience(TOKEN_ENDPOINT)
.setIssuedAt()
.setExpirationTime('2m') // must be within 5 minutes
.setJti(randomUUID()) // fresh for every assertion
.sign(privateKey)
}
Step 2 — Exchange it for an access token
The assertion goes to the token endpoint as a form-encoded POST. The parameters must be in the body, not the query string.
Note the resource parameter. It restricts the token to the GraphQL API, and you should always include it — Restricting the token to GraphQL explains why, and what to do if your client is not allowed to request it yet.
async function fetchAccessToken() {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: await buildClientAssertion(),
scope: 'adverts:read orders:read',
// Restrict this token to the GraphQL API. See "Restricting the token
// to GraphQL" below for why, and what to do if it is refused.
resource: 'https://your-marketplace.example/graphql',
})
const response = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!response.ok) {
throw new Error(`Token request failed: ${response.status} ${await response.text()}`)
}
return response.json() // { access_token, token_type, expires_in, scope }
}
A successful response body:
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "adverts:read orders:read"
}
There is no refresh token, and that is deliberate: a refresh token exists to avoid sending a user back through a sign-in screen, and there is no user here. When your token nears expiry, request another one exactly the same way.
The scope in the response is what you actually received, which may be narrower than what you asked for — see Scopes.
Calling the GraphQL API
Send the access token as a bearer token:
// const { access_token: accessToken } = await fetchAccessToken()
async function searchForProducts(accessToken) {
const response = await fetch('https://your-marketplace.example/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `query searchForProducts {
advertsWhere(first: 10) {
nodes { id legacyId title }
}
}`,
}),
})
return response.json()
}
The only difference from the API key examples elsewhere in these docs is the Authorization header: it carries a short-lived access token instead of a long-lived key. Everything about the queries themselves is identical.
Restricting the token to GraphQL
The token request above asks for a specific resource:
resource=https://your-marketplace.example/graphql
Always include it. The token you get back is addressed to the GraphQL API and nothing else, so a token that leaks cannot be replayed against a different API on the same host.
Your client has to be allowed to request that resource, so ask the operator to configure it when your client is created. If it is not allowed, the token request fails with invalid_target rather than quietly handing you a broader token.
This does not work for the REST API
Asking for resource=https://your-marketplace.example/api/v2 will get you a token, and the REST API will then refuse every request you make with it.
The REST API resolves a seller from the credential and gates its responses on that seller. A client_credentials token represents your service rather than a person, so it carries no seller context, and every REST request returns 401. No amount of client configuration changes this.
If you need the REST API, you need a credential that has seller context: a seller API key, or a seller-delegated token from the authorization_code flow.
Scopes
Scopes work the same way for OAuth clients as they do for API keys: the same bucket:level vocabulary (adverts:read, orders:write, refunds:manage, …) with the same read → write → manage hierarchy. The API Key Scopes playbook has the full resource catalogue and guidance on choosing them, and all of it applies here.
Two things are specific to OAuth.
You ask for scopes per request. The scope parameter on your token request says what you want. What you get is the intersection of that and the scopes on your client — so you can deliberately request less than your client is entitled to for a particular job, but never more. The response tells you what you actually received.
Missing scopes fail loudly. A request lacking a required scope returns a MISSING_SCOPE error rather than silently empty data:
{
"errors": [
{
"message": "Missing required scope: orders:write",
"extensions": {
"code": "MISSING_SCOPE",
"scope": "orders:write"
},
"path": ["orderCreate"]
}
],
"data": { "orderCreate": null }
}
The fix is for the operator to add the scope to your client. Unlike an API key — whose scope set is fixed when it is issued, so widening it means issuing a new key and redeploying — an OAuth client’s scopes can be edited in place. You keep the same client_id and the same keypair; only the scope set changes.
Running this in production
Cache the access token. Keep it and reuse it until it is close to expiring — expires_in tells you how long you have, and renewing at around 80% of that leaves comfortable headroom. Do not request a token per API call: it is slower, and the token endpoint is rate-limited per client, so a busy integration that fetches a token every time will start being refused.
Handle expiry. Treat an authentication failure on an API call as “get a new token and retry once”, rather than as a fatal error. Tokens can also be revoked, so this can happen before the hour is up.
Rotate signing keys through your JWKS. Add the new key to your JWKS while the old one is still published, start signing with the new kid, then remove the old key once nothing signs with it. Both are valid during the overlap, so there is no moment where a request can fail.
Keep the private key in a secret manager, not in your repository and not baked into an image. It is the only thing standing between an attacker and your client’s access.
Acting on behalf of a person
If your service acts for a signed-in seller or operator admin rather than on its own behalf, authorization_code is the grant you want, for the reasons in Which grant do you need? above.
This playbook does not walk through that flow, but here is what it involves, so that you can judge the work:
- The person signs in with Marketplacer and is shown a consent screen naming the scopes you asked for. Consent is always explicit; there is no way to skip it.
- PKCE is required, using the
S256challenge method. Any modern OAuth library does this for you. - Your redirect URIs must be registered in advance and are matched exactly — no wildcards, no trailing-slash mismatches. Give them to the operator when your client is created. (A native app may vary only the port of a loopback redirect.)
- The token is confined to that person. A seller’s token reaches only that seller’s records; an admin’s token carries that admin’s own permissions.
- A seller’s scopes are capped to what a seller is allowed to do, so a seller-delegated token can never exceed what that seller could do in the Seller Portal themselves.
- Refresh tokens are available, which is how you keep access without sending the person back through consent. They rotate each time you use one, and presenting a spent refresh token revokes the whole chain — so always store the newest one, and never use one twice.
- Client authentication is the same. Everything above about
private_key_jwtapplies to the code exchange too.
Your client has to be created for this flow — the Sign in users scenario, or Sign in users and call APIs if it does both — so tell the operator which you need.
These tokens do work against the REST API
A token issued on behalf of a seller carries that seller’s context, so unlike a client_credentials token it can be used against the REST API (/api/v2) as well as GraphQL. The same is true of a token issued to an operator admin who is acting on a seller’s behalf.
Two things to get right. Request the REST resource, so the token is addressed there:
resource=https://your-marketplace.example/api/v2
And make sure the seller has API access enabled — the same operator-controlled setting a seller API key needs.
A token for an operator admin acting as themselves resolves no seller, so like client_credentials it cannot drive the REST API.
DPoP: binding tokens to your key
By default an access token is a bearer token: whoever holds it can use it. If it leaks, it is usable by whoever found it until it expires.
DPoP (RFC 9449) changes that. Your service proves possession of a key on every request, and the access token is bound to that key. A stolen token is useless on its own, because the thief cannot produce the proof.
When it is worth it
- Your tokens pass through infrastructure you do not fully control — shared gateways, egress proxies, third-party log aggregation or APM tooling
- Your client holds high-privilege scopes, such as
refunds:manageor anything payments-related, where a single misused token is expensive - Your threat model assumes credentials will leak, or a compliance obligation requires proof-of-possession
When plain bearer is fine
If your token is obtained and used inside a service you control, over TLS, with read-only scopes, bearer is a reasonable choice. The one-hour lifetime already limits the damage from a leak.
What it costs
Two things to budget for:
- A freshly signed proof on every request — not once per token. Each proof is bound to the HTTP method and URL it is for.
- Nonce handling. We may require a server-supplied nonce, and your client has to cope with that: a request can be rejected with a
DPoP-Nonceheader telling you which nonce to use, and you are expected to retry with it. Nonces expire, so this is not one-time setup — it is a loop your client lives with for as long as it runs.
That second point is the part most often underestimated. Do not adopt DPoP without either a client library that handles nonce retries for you, or a deliberate decision to implement that loop yourself.
DPoP is enabled per client, so ask the operator to turn it on when your client is created.
Troubleshooting
| Symptom | Likely cause |
|---|---|
invalid_client | The assertion failed verification. Check that iss and sub both equal your client_id, that aud is the full token endpoint URL, that the kid matches a key in your JWKS, and that your jwks_uri is reachable over HTTPS. |
invalid_client, mentioning a used assertion | You sent the same jti twice. Generate a fresh assertion per request. |
invalid_client, mentioning assertion lifetime | exp is more than 5 minutes after iat. Shorten it. |
unauthorized_client | Your client is not enabled for client_credentials. The operator needs to check the client’s scenario. |
invalid_request about parameters | Parameters were sent in the query string, or one was sent twice. Everything goes in the form-encoded body, once each. |
MISSING_SCOPE on a GraphQL field | The scope is not on your client, or you did not ask for it. Check the scope value in the token response against what you needed. |
invalid_target on the token request | Your client is not allowed to request the resource you asked for. The operator adds the GraphQL API to the client’s allowed resources. |
A 401 on /graphql with a token that looks valid | The token may be addressed to a different API. Check that the resource you requested is the GraphQL endpoint. |
A 401 on every /api/v2 request | Expected, and not fixable by configuration. A client_credentials token has no seller context and the REST API requires one — see Restricting the token to GraphQL. |
Further reading
- API Key Scopes — the resource catalogue, shared with OAuth clients
- API Key Rotation — for the API keys you still use
- OAuth 2.1 — the specification this implements
- RFC 7523 —
private_key_jwtclient assertions - RFC 8707 — resource indicators, the
resourceparameter - RFC 9449 — DPoP