eBay API 11 min read

Connect an eBay Seller Account over OAuth (Keyset, RuName, getOrders)

Wiring one eBay seller into a custom app end to end: the production keyset, why redirect_uri is the RuName string and not your callback URL, the read-only scopes, token exchange with Basic auth, the ~18-month non-rotating refresh token, and the first getOrders pull.

Updated Aug 2026
Connect an eBay Seller Account over OAuth (Keyset, RuName, getOrders)

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.

The eBay OAuth authorization-code flow for connecting one seller account 1 · Your app builds the authorize URL client_id · scope · state · redirect_uri = RuName 2 · auth.ebay.com/oauth2/authorize Seller signs in, reviews the scopes, clicks Agree 3 · eBay redirects to the RuName's accepted URL Your callback receives the code (plus state) 4 · POST api.ebay.com/identity/v1/oauth2/token Basic base64(AppID:CertID) · form-encoded 5 · Tokens returned access token 2h · refresh token ~18 months, non-rotating 6 · Call the Sell APIs getUser on apiz.ebay.com · getOrders on api.ebay.com

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.

eBay developer portal User Tokens page showing the production keyset, the App ID field, and the Auth'n'Auth vs OAuth toggle, with account identifiers redacted

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.

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 getUser and 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 headerbase64("AppID:CertID")not in the body.
  • The body is form-encoded (application/x-www-form-urlencoded), and redirect_uri is 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_in is 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.

eBay Review and Grant Application Access consent screen showing the requested permissions and the Agree and Continue button, with the application name redacted

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 lastmodifieddate for incremental sync, not creationdate. 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 getOrders returns them together. Read lineItems[].listingMarketplaceId (for example EBAY_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.

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.

Found this helpful? Share it:

WhatsApp