redirect_uri is a RuName, not your callback URL #
You're wiring one eBay seller into your own app — pull their orders, show them in a dashboard. You build the authorize URL the way every OAuth walkthrough shows it: client_id, response_type=code, and redirect_uri set to https://api.example.com/ebay/oauth/callback. eBay rejects it.
The fix is one line, and it's the single most confusing thing about eBay OAuth: eBay's redirect_uri is not a URL. It's a RuName — an opaque string eBay generates, shaped like YourApp-YourApp-PRD-abc123-def456. Your real callback URL is configured separately, on the RuName, as its "auth accepted URL". You pass the RuName; eBay looks up the accepted URL behind it and redirects the browser there with the code.
Once that clicks, the rest is standard OAuth 2.0 authorization-code — with a handful of eBay-specific edges that each cost an hour. Here's the whole path, connecting one seller end to end.
Start with a production keyset, and choose OAuth over Auth'n'Auth #
Everything hangs off a production keyset in the eBay developer portal: an App ID (your client id), a Cert ID (your client secret), and a Dev ID. One keyset, one app — it serves every seller who consents. You do not need a keyset per seller.
On the same screen eBay offers two auth systems, and the choice is permanent enough to matter. Auth'n'Auth is the legacy token system tied to the old Trading API. OAuth ("new security") is the modern one, and it's what every REST Sell API expects. Pick OAuth. An Auth'n'Auth token will not authorize a sell.fulfillment call, and you don't want to discover that after building half the integration.
Mental model: the keyset identifies your software; a RuName identifies where a consenting seller's browser lands afterward; the seller's refresh token identifies one seller. Three different things, and eBay's portal spreads them across three screens.
The RuName is gated behind 'Confirm your Legal Address' #
To create the RuName you click Add eBay Redirect URL. If your developer account hasn't completed its contact details, eBay stops you with a "Confirm your Legal Address" form first — legal name, business name, and a full postal address. This is the step that actually blocked us: the RuName screen will not produce a RuName until that form is filled and saved.
Past it, the RuName row carries three fields you set: a Display Title (what the seller sees on the consent screen), a privacy policy URL (eBay requires a real, secure one), and the auth accepted URL — your backend callback. Then flip OAuth Enabled on for that RuName.
One more trap: editing an existing RuName's accepted URL in the portal did not reliably persist for us, while setting it on a freshly created RuName did. If a redirect keeps landing on eBay's generic "success" page instead of your callback, check that the accepted URL actually saved — a blank accepted URL falls back to eBay's default pages.
Three read-only scopes, and why #
You request scopes in the authorize URL, space-separated. For read-only order sync, three do the job:
https://api.ebay.com/oauth/api_scope/sell.fulfillment.readonly https://api.ebay.com/oauth/api_scope/sell.account.readonly https://api.ebay.com/oauth/api_scope/commerce.identity.readonly
- sell.fulfillment.readonly — the orders themselves via
getOrders: buyer ship-to, line items, pricing. - sell.account.readonly — account-level settings you often want alongside orders.
- commerce.identity.readonly — the one people forget. It's what lets you call
getUserand learn which seller just connected (more below).
Ask only for what you use. A read-only integration that requests write scopes is a consent screen the seller is more likely to abandon, and a bigger blast radius if a token leaks.
Token exchange: Basic auth, base64(AppID:CertID), form-encoded #
The seller consents, eBay redirects to your callback with the code, and you exchange it at the token endpoint. Two details trip people:
- The credentials go in an HTTP Basic auth header —
base64("AppID:CertID")— not in the body. - The body is form-encoded (
application/x-www-form-urlencoded), andredirect_uriis the RuName again, matching the authorize call.
import base64, os, httpx
async def exchange_code(code: str) -> dict:
basic = base64.b64encode(
f'{os.environ["EBAY_CLIENT_ID"]}:{os.environ["EBAY_CERT_ID"]}'.encode()
).decode()
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": os.environ["EBAY_RUNAME"], # the RuName, not a URL
}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {basic}",
}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
"https://api.ebay.com/identity/v1/oauth2/token",
data=data, headers=headers,
)
resp.raise_for_status()
return resp.json()
Sandbox is the same shape with api.sandbox.ebay.com. The response carries access_token, refresh_token, expires_in, and refresh_token_expires_in — which brings us to the part that changes how you store tokens.
2h access, ~18-month non-rotating refresh: do not re-persist it #
eBay's token lifetimes:
- Access token: 2 hours (
expires_in: 7200). - Refresh token: about 18 months (
refresh_token_expires_inis roughly 47,304,000 seconds) — and, the important part, it does not rotate.
Because the refresh token is stable, you persist it once, at connect time, and never again. The refresh grant returns a fresh access token and no new refresh token; there is nothing new to save. Contrast Etsy, whose refresh token rotates on every use — there you must write the new refresh token back on each refresh, or the next one fails. Carry Etsy's habit to eBay and you add a pointless write on the hot path; do the reverse and Etsy breaks a day later.
async def store_tokens(account, payload: dict):
account.access_token = encrypt(payload["access_token"])
account.token_expires_at = now() + timedelta(seconds=payload["expires_in"])
# Only the initial code exchange returns a refresh token. The refresh grant
# does NOT: eBay's refresh token is long-lived and non-rotating.
if payload.get("refresh_token"):
account.refresh_token = encrypt(payload["refresh_token"])
account.refresh_expires_at = now() + timedelta(
seconds=payload["refresh_token_expires_in"]
)
await save(account)
Gotcha: the refresh grant needs the scope parameter sent again, with the same scopes. Omit it and eBay can hand back an access token missing the scope you rely on.
Which seller connected? getUser lives on apiz.ebay.com #
After the exchange you have tokens, but you don't yet know who they belong to — which matters the moment you onboard more than one account and need to label the row correctly. getUser answers it, and it sits on a host that's easy to miss:
GET https://apiz.ebay.com/commerce/identity/v1/user Authorization: Bearer <access_token>
Note apiz.ebay.com, not api.ebay.com. The Identity API lives on a different host from the Sell APIs, and a request to the wrong one 404s in a way that reads like a broken path. It returns username, userId, registrationMarketplaceId, and account type — enough to confirm the connection and store a stable id.
This is also the check that saves you at scale. If you send a seller a per-account consent link but they happen to be signed into a different eBay account, getUser reports the real identity — so you can refuse the mismatch instead of silently wiring one seller's tokens onto another seller's row.
The first getOrders: 90-day default, lastmodifieddate, and marketplaces #
Orders come from the Sell Fulfillment API:
GET https://api.ebay.com/sell/fulfillment/v1/order
Three things about that first call:
- No filter means the last 90 days, only. Call it bare and you get a 90-day window, not "all orders". For a full backfill you page through explicit date ranges.
- Use
lastmodifieddatefor incremental sync, notcreationdate. A last-modified filter also catches status changes — a ship, a cancel, a refund — on older orders, which a creation-date sweep silently misses. - One token spans all of a seller's marketplaces. A single eBay account can sell on multiple sites under one token, and
getOrdersreturns them together. ReadlineItems[].listingMarketplaceId(for exampleEBAY_US,EBAY_GB) to know which one each order came from.
from datetime import datetime, timedelta, timezone
def since(hours: int) -> str:
t = datetime.now(timezone.utc) - timedelta(hours=hours)
return t.strftime("%Y-%m-%dT%H:%M:%S.000Z")
params = {
"filter": f"lastmodifieddate:[{since(48)}..]",
"limit": 200,
"offset": 0,
}
Gotcha: on getOrders, cancelStatus.cancelRequests always comes back as an empty array — even for an order with a live cancellation. eBay populates it only on the single-order getOrder call. Key your cancellation logic off getOrders and you'll conclude nobody ever cancels.
The re-auth nobody calendars: 18 months #
The 18-month refresh token feels infinite during a build, which is exactly why it's the thing that breaks in production a year and a half later. It does not auto-renew. When it expires, the only fix is the seller going through the consent screen again — there is no silent server-side refresh past that point.
So the operational task, the moment you connect a seller: store refresh_token_expires_in and set a reminder ahead of it. Alert yourself weeks before expiry, per account, and re-drive consent before it lapses rather than after orders quietly stop syncing. For a multi-account setup this is a small watchdog, not a sticky note — every account expires on its own clock.
Doing this across several marketplaces at once? The same shape — identify the account from the token, store the expiry, alert before it lapses — applies to Amazon's LWA refresh tokens and Etsy's rotating ones, just with different lifetimes and rotation rules. Related: how buyer-PII access works on Amazon SP-API, and getting SP-API access in the first place.
FAQ #
Is eBay's redirect_uri a URL or a RuName?
A RuName. You pass the RuName string as redirect_uri in both the authorize and token calls. Your actual callback URL is configured separately as the RuName's "auth accepted URL", and eBay redirects there with the authorization code.
Do I need a separate eBay keyset for each seller?
No. One production keyset (App ID, Cert ID, Dev ID) serves every seller. Each seller connects once through OAuth consent and gets their own refresh token; the keyset is shared across all of them.
How long do eBay OAuth tokens last?
The user access token lasts 2 hours. The refresh token lasts about 18 months and does not rotate, so you store it once and re-mint access tokens from it. When the refresh token expires the seller must re-authorize.
Why does eBay getUser return 404?
Almost always because it's on the wrong host. getUser is at apiz.ebay.com/commerce/identity/v1/user, not api.ebay.com. The Identity API runs on apiz; the Sell APIs run on api.
Why is cancelStatus.cancelRequests always empty on getOrders?
By design. getOrders returns that array empty regardless of actual cancellations; only the single-order getOrder call populates it. Use getOrder when you need cancellation detail.
Related guides
Amazon Ads API
Amazon Ads API Rate Limits Explained (And How to Stop Hitting Them)
A 429 storm from the Amazon Ads API almost always means your retry logic is wrong, not that your limits are too low. Here's how the per-endpoint token buckets actually work, what the response headers tell you, and the request patterns that keep you comfortably under the limit.
Read guide →Amazon SP-API
The SP-API Reports Pattern Everyone Gets Wrong (Async, Step by Step)
The SP-API Reports API is asynchronous — request, poll, download, decompress — and most broken integrations either treat it like a synchronous call or poll it straight into a throttle. Here's the correct four-step flow, the status states that trip people up, and the gzip gotcha at the end.
Read guide →Guides
Custom PPC Automation vs Pacvue & Perpetua: an Honest Comparison
I build custom Amazon PPC automation for a living and I still tell most people to buy the SaaS. Here's a straight comparison of custom builds versus tools like Pacvue and Perpetua — what each does better, the real cost math, and how to tell which side of the line you're actually on.
Read guide →Don't want to build this yourself?
I set this up for sellers and agencies every week. Book a free 30-minute audit of your Amazon data & PPC setup — you'll leave with a plan either way, whether we work together or not.