OAuth for Services and Integrations

Why a service or integration should authenticate with an OAuth client rather than an API key, which grant to choose, how an operator registers a client, and a worked example of obtaining and using an access token with private_key_jwt.

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 doesGrant to useThe token acts asWhat it can reach
Acts as itself — scheduled syncs, back-office jobs, no end user involvedclient_credentialsYour serviceThe whole marketplace, limited by the scopes on your client
Acts on behalf of a signed-in seller or operator adminauthorization_codeThe person who signed in and consentedOnly 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.

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.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. 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.
  2. Choose the Backend API access scenario — the one for a server-to-server integration with no end user.
  3. Set the authentication method to private_key_jwt and record the jwks_uri (or paste the JWKS JSON).
  4. 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.
  5. 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.
  6. Send the resulting client_id back 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.

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.

Endpoints

PurposeEndpoint
Token endpointPOST https://<your-marketplace>/oauth/token
Discovery documentGET https://<your-marketplace>/.well-known/oauth-authorization-server
Our public keysGET 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.

ClaimValue
issyour client_id
subyour client_id — the same value as iss
audthe full token endpoint URL, for example https://your-marketplace.example/oauth/token
iatnow
expno more than 5 minutes after iat
jtia 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.

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.

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 S256 challenge 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_jwt applies 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.

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:manage or 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:

  1. A freshly signed proof on every request — not once per token. Each proof is bound to the HTTP method and URL it is for.
  2. 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-Nonce header 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

SymptomLikely cause
invalid_clientThe 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 assertionYou sent the same jti twice. Generate a fresh assertion per request.
invalid_client, mentioning assertion lifetimeexp is more than 5 minutes after iat. Shorten it.
unauthorized_clientYour client is not enabled for client_credentials. The operator needs to check the client’s scenario.
invalid_request about parametersParameters 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 fieldThe 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 requestYour 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 validThe token may be addressed to a different API. Check that the resource you requested is the GraphQL endpoint.
A 401 on every /api/v2 requestExpected, 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