christianjbrown / ebay-browse-api-sdk
A strongly-typed, read-only PHP 8.5+ client for the eBay Browse API that returns typed model objects instead of raw arrays.
Package info
github.com/christianjbrown/ebay-browse-api-sdk-php
pkg:composer/christianjbrown/ebay-browse-api-sdk
Requires
- php: ^8.5
- christianjbrown/api-client: ^3.0
- christianjbrown/key-value-store: ^3.0
- christianjbrown/oauth2-client: ^2.1
- psr/clock: ^1.0
- psr/container: ^2.0
- symfony/clock: ^8.0
- symfony/dependency-injection: ^8.0
Requires (Dev)
- brianium/paratest: ^7.25
- christianjbrown/code-quality-scripts: ^1.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-01 13:35:40 UTC
README
A strongly-typed PHP client for the eBay Browse API. It reads eBay's public listing data — a single item, the items in a multi-variation listing, keyword and image searches, and part compatibility — returning plain, typed model objects rather than raw arrays.
The client is read-only and covers the whole public Browse surface. It authenticates with an application (client-credentials) OAuth2 token, so it needs no seller consent and no user token. Two things it does that a thin wrapper would not: it exposes estimatedAvailabilities, which carries the sold and remaining quantities that replaced the retired Shopping API's QuantitySold, and it turns eBay's "item not found" response into its own ItemNotFoundException so a caller can tell an ended listing apart from a failed request.
Supported endpoints
| Resource | Client | Endpoint(s) | Returns |
|---|---|---|---|
| Items | getItemApi() |
GET /item/{item_id}, GET /item/get_item_by_legacy_id, GET /item/get_items_by_item_group |
ItemInterface / ItemGroupInterface |
| Items (bulk) | getItemApi() |
GET /item |
ItemsResponseInterface |
| Item compatibility | getItemCompatibilityApi() |
POST /item/{item_id}/check_compatibility |
CompatibilityResponseInterface |
| Item summaries | getItemSummaryApi() |
GET /item_summary/search, POST /item_summary/search_by_image |
SearchPagedCollectionInterface |
⚠️ getItems() (GET /item) is a Limited Release Browse API call, available to select partners only. It needs an extra OAuth2 scope — https://api.ebay.com/oauth/api_scope/buy.item.bulk on top of the application's usual https://api.ebay.com/oauth/api_scope — which the client requests for you automatically for this one call; every other call keeps using the plain scope. Calling getItems() without that scope granted on your application answers with an authorization error.
ItemInterface and ItemSummaryInterface now cover eBay's full published field set: Item gained addonServices, authenticityGuarantee, authenticityVerification, availableCoupons, charityTerms, conditionDescriptors, ecoParticipationFee, gender, hazardousMaterialsLabels, inferredEpid, manufacturer, minimumPriceToBid, pattern, priceDisplayCondition, primaryItemGroup, primaryProductReviewRating, productFicheWebUrl, productSafetyLabels, qualifiedPrograms, quantityLimitPerBuyer, repairScore, reservePriceMet, responsiblePersons, sellerCustomPolicies, size, sizeSystem, sizeType, taxes, tyreLabelImageUrl and watchCount; ItemSummary gained compatibilityMatch, compatibilityProperties, distanceFromPickupLocation, pickupOptions, priceDisplayCondition, qualifiedPrograms and tyreLabelImageUrl. Product, PaymentMethod, Seller and ShippingOption also gained the remaining fields eBay documents for them (product identities and aspect groups; payment/seller instructions; userId and sellerLegalInfo; fulfilledThrough, shipToLocationUsedForEstimate and trademarkSymbol).
✔️ Prerequisites
💡 If you're on MacOS and have Homebrew, PHP and Composer will install with brew install composer.
🏗️ Installation
For your composer-enabled project:
composer require christianjbrown/ebay-browse-api-sdk
💻 Usage
The Browse API authenticates every request with an application access token — eBay's OAuth2 client_credentials grant against https://api.ebay.com/identity/v1/oauth2/token, scope https://api.ebay.com/oauth/api_scope. This client fetches and caches that token for you, so you supply only your app's client id and client secret from the eBay developer program.
Every request also carries a marketplace id (X-EBAY-C-MARKETPLACE-ID), which decides the site the data comes from and the currency prices are quoted in. That, plus the optional X-EBAY-C-ENDUSERCTX and Accept-Language headers, lives in a small Marketplace value object.
You supply four things to the Browse entry point, plus an optional fifth:
- your app's client id,
- your app's client secret,
- a
MarketplaceInterfacenaming the marketplace to read, - a
TtlAwareKeyValueStoreInterfaceto hold the current access token (an in-memory store is fine — tokens last two hours and are re-fetched as needed; a shared store just saves round-trips), - an
ApiHostInterfacenaming which eBay environment to call:ApiHost::production()orApiHost::sandbox()(see Overriding the API host).
BrowseFactory takes the API host and builds the Browse facade:
use ChristianBrown\EBay\Browse\BrowseFactory; use ChristianBrown\EBay\Browse\Enums\MarketplaceId; use ChristianBrown\EBay\Browse\Http\ApiHost; use ChristianBrown\EBay\Browse\Marketplace; use ChristianBrown\KeyValueStore\MemoryKeyValueStore; use Symfony\Component\Clock\NativeClock; $browse = (new BrowseFactory(ApiHost::production()))->create( 'your-client-id', 'your-client-secret', new Marketplace(MarketplaceId::EBAY_GB), // optionally: end-user context, Accept-Language new MemoryKeyValueStore(new NativeClock()) ); $itemApi = $browse->getItemApi(); // ItemApiInterface $itemSummaryApi = $browse->getItemSummaryApi(); // ItemSummaryApiInterface $compatibilityApi = $browse->getItemCompatibilityApi(); // ItemCompatibilityApiInterface
📡 Overriding the API host
Every request, including the OAuth2 token exchange, goes to the host you give BrowseFactory. To
point the client at eBay's sandbox, pass ApiHost::sandbox() instead of ApiHost::production():
use ChristianBrown\EBay\Browse\BrowseFactory; use ChristianBrown\EBay\Browse\Http\ApiHost; $browse = (new BrowseFactory(ApiHost::sandbox()))->create( 'your-sandbox-client-id', 'your-sandbox-client-secret', new Marketplace(MarketplaceId::EBAY_GB), new MemoryKeyValueStore(new NativeClock()) );
eBay runs the Buy APIs, including Browse, through a different sandbox gateway host than the rest of
the platform: ApiHost::sandbox() calls the Browse API on apiz.sandbox.ebay.com and the OAuth2
token endpoint on api.sandbox.ebay.com. ApiHost::production() calls both on
api.ebay.com. For any other host (a proxy, a mock server in tests), construct new ApiHost($browseApiBaseUrl, $oauthTokenUrl) directly.
📦 Reading one item
getOneById() takes a Browse item id (v1|123456789012|0); getOneByLegacyId() takes the plain numeric id you see in an eBay URL and optionally a variation id or SKU.
$item = $itemApi->getOneByLegacyId('123456789012'); // ItemInterface printf("%s — %s %s\n", $item->getTitle(), $item->getPrice()?->getValue(), $item->getPrice()?->getCurrency()); printf("Seller: %s (%s%% of %d)\n", $item->getSeller()?->getUsername(), $item->getSeller()?->getFeedbackPercentage(), $item->getSeller()?->getFeedbackScore() ); // The quantity fields that replaced the retired Shopping API's QuantitySold. foreach ($item->getEstimatedAvailabilities() as $availability) { printf("%s: %d sold, %d remaining\n", $availability->getEstimatedAvailabilityStatus(), // IN_STOCK, OUT_OF_STOCK, … $availability->getEstimatedSoldQuantity(), $availability->getEstimatedRemainingQuantity() ); }
fieldgroups is passed straight through, so getOneById($itemId, 'PRODUCT') adds the catalogue product block, and the multi-variation parent of a listing is read with:
$group = $itemApi->getMultipleByItemGroupId('987654321098'); // ItemGroupInterface foreach ($group->getItems() as $variation) { printf("%s — %s\n", $variation->getItemId(), $variation->getTitle()); }
📦 Reading several items at once
getItems() (GET /item) reads up to 20 items or up to 10 item groups in a single call, given one of itemIds or itemGroupIds (never both). It is a Limited Release call — see the warning above — and returns only a reduced field set per item (no title, description or seller, for example), so treat every field on the returned items as optional even where a single-item read would guarantee it.
$itemsResponse = $itemApi->getItems(['v1|123456789012|0', 'v1|234567890123|0']); // ItemsResponseInterface foreach ($itemsResponse->getItems() as $item) { printf("%s: %s %s\n", $item->getItemId(), $item->getPrice()?->getValue(), $item->getPrice()?->getCurrency()); } printf("%d warnings\n", count($itemsResponse->getWarnings()));
Reading by item group instead:
$itemsResponse = $itemApi->getItems(itemGroupIds: ['987654321098']);
quantityForShippingEstimate is accepted here and by getOneById(), getOneByLegacyId() and getMultipleByItemGroupId(), and is passed straight through as the quantity_for_shipping_estimate query parameter.
🔍 Searching
search() exposes the full parameter set — keyword, GTIN, EPID, charity ids, category ids, aspect and compatibility filters, the general filter expression, sort, fieldgroups, auto_correct, and limit/offset pagination.
$collection = $browse->getItemSummaryApi()->search( q: 'vintage film camera', categoryIds: '15230', filter: 'buyingOptions:{FIXED_PRICE},price:[1..25],priceCurrency:GBP', sort: 'price', fieldgroups: 'MATCHING_ITEMS,ASPECT_REFINEMENTS', limit: 25, offset: 0 ); printf("%d matches, showing %d\n", $collection->getTotal(), count($collection->getItemSummaries())); foreach ($collection->getItemSummaries() as $summary) { printf("%s — %s %s\n", $summary->getTitle(), $summary->getPrice()?->getValue(), $summary->getPrice()?->getCurrency()); } // Refinements come back when you ask for them via fieldgroups. foreach ($collection->getRefinement()?->getAspectDistributions() ?? [] as $aspect) { printf("%s\n", $aspect->getLocalizedAspectName()); foreach ($aspect->getAspectValueDistributions() as $value) { printf(" %s (%d)\n", $value->getLocalizedAspectValue(), $value->getMatchCount()); } }
💡 eBay only returns itemSummaries alongside refinements when MATCHING_ITEMS is one of the fieldgroups. Ask for ASPECT_REFINEMENTS on its own and you get the refinements and nothing else.
searchByImage() takes a base64-encoded image and the same refinement parameters:
$collection = $browse->getItemSummaryApi()->searchByImage( base64_encode(file_get_contents('photo.jpg')), categoryIds: '15230', limit: 10 );
🔧 Part compatibility
check() takes a Browse item id and a map of compatibility aspect names to values. The accepted names vary by marketplace and category — eBay answers 11507 listing the ones it does not recognise.
$response = $compatibilityApi->check('v1|236751498408|0', [ 'Car Make' => 'Honda', 'Model' => 'Civic', 'Engine' => '2.0', ]); echo $response->getCompatibilityStatus(), "\n"; // COMPATIBLE, NOT_COMPATIBLE, UNDETERMINED
🚨 Error handling
Everything this library throws implements ChristianBrown\EBay\Browse\Exception\ExceptionInterface, so a single catch covers it all:
use ChristianBrown\EBay\Browse\Exception\ExceptionInterface; try { $item = $itemApi->getOneByLegacyId('210987654321'); } catch (ExceptionInterface $exception) { // Anything this library throws lands here. }
There are three concrete types:
ItemNotFoundException(extendsRuntimeException) — eBay answered404: the listing has ended, was deleted, or never existed. Catch this on its own to tell "this listing is gone" apart from "the request failed"; the original response is kept as the exception's previous throwable.UnexpectedResponseException(extendsRuntimeException) — the Browse API returned a body the client or a transformer couldn't parse (a missing or mis-typed field, an empty response).MissingInputException(extendsInvalidArgumentException) — bad caller input, e.g.check()with no compatibility properties orsearchByImage()with an empty image.
use ChristianBrown\EBay\Browse\Exception\ItemNotFoundException; try { $item = $itemApi->getOneByLegacyId($legacyItemId); } catch (ItemNotFoundException) { // The listing has ended — not an error worth retrying. }
All three live in src/Exception/. Other request-level failures (network errors, 400s, throttling) surface as RequestExceptionInterface from christianjbrown/api-client, and token failures as RequestExceptionInterface from christianjbrown/oauth2-client. Both are outside this library's exception hierarchy. A BadResponseExceptionInterface carries getDecodedBody(), which is where eBay's errors payload (its errorId, domain, category, message and longMessage) can be read.
Under the hood, Browse wires the clients, their transformer chains, and the OAuth machinery through a Symfony dependency-injection container. If you don't want the container, you can build the same chain by hand — as shown below.
Wiring the clients
Every client takes a request sender, its transformer chain, a CredentialsInterface and an ApiHostInterface. The credentials and the host are the same for all three, so they are built once:
use ChristianBrown\ApiClient\ApiClientFactory; use ChristianBrown\ApiClient\ClientOptions; use ChristianBrown\EBay\Browse\Auth\Credentials; use ChristianBrown\EBay\Browse\Enums\MarketplaceId; use ChristianBrown\EBay\Browse\Http\ApiHost; use ChristianBrown\EBay\Browse\Marketplace; use ChristianBrown\KeyValueStore\MemoryKeyValueStore; use ChristianBrown\OAuth2Client\ClientCredentialsTokenManagerFactory; use ChristianBrown\OAuth2Client\Lock\NullLock; use Symfony\Component\Clock\NativeClock; // Shared JSON request sender (wires Guzzle for you). $requestSender = (new ApiClientFactory(new ClientOptions()))->create()->getJsonApiRequestSender(); // ApiHost::production() talks to api.ebay.com; ApiHost::sandbox() switches // every client and the token exchange below to eBay's sandbox gateway. $apiHost = ApiHost::production(); // OAuth2 client-credentials machinery. The lock is required: NullLock never // blocks, which is right when one process refreshes the token at a time. $clock = new NativeClock(); $tokenManager = (new ClientCredentialsTokenManagerFactory($clock))->create( $requestSender, new MemoryKeyValueStore($clock), $apiHost->oauthTokenUrl(), new NullLock() ); $credentials = new Credentials( $tokenManager, new Marketplace(MarketplaceId::EBAY_GB), 'your-client-id', 'your-client-secret' );
The transformer chains are built bottom-up from the leaves, sharing one instance of each leaf across every branch that needs it — exactly as the container wires them. The compatibility client has the shortest chain:
use ChristianBrown\EBay\Browse\Api\ItemCompatibilityApi; use ChristianBrown\EBay\Browse\Transformer\CompatibilityResponseTransformer; use ChristianBrown\EBay\Browse\Transformer\ErrorParametersTransformer; use ChristianBrown\EBay\Browse\Transformer\ErrorParameterTransformer; use ChristianBrown\EBay\Browse\Transformer\ErrorsTransformer; use ChristianBrown\EBay\Browse\Transformer\ErrorTransformer; use ChristianBrown\EBay\Browse\Transformer\StringsTransformer; $errorsTransformer = new ErrorsTransformer( new ErrorTransformer( new ErrorParametersTransformer(new ErrorParameterTransformer()), new StringsTransformer() ) ); $compatibilityApi = new ItemCompatibilityApi( $requestSender, new CompatibilityResponseTransformer($errorsTransformer), $credentials, $apiHost );
ItemTransformer is a composition of eight part transformers (ItemDescriptionTransformer, ItemConditionTransformer, ItemMediaTransformer, ItemPricingTransformer, ItemFulfilmentTransformer, ItemListingTransformer, ItemProductTransformer and ItemComplianceTransformer), each taking the nested transformers for its own fields; ItemSummaryTransformer takes a longer constructor. Read the argument list off the class and hand each nested transformer in, in order. ItemApi and ItemSummaryApi take the same $requestSender, their own transformer chain, $credentials and $apiHost, plus one KeyedCacheInterface argument per independent cache they keep: ItemSummaryApi takes one (for search()), ItemApi takes four (for getOneById(), getOneByLegacyId(), getMultipleByItemGroupId() and getItems(), in that order) and an ItemsResponseTransformerInterface before the getItems() cache, each cache its own ArrayKeyedCache instance:
use ChristianBrown\EBay\Browse\Api\ItemApi; use ChristianBrown\EBay\Browse\Cache\ArrayKeyedCache; $itemApi = new ItemApi( $requestSender, $itemTransformer, $itemGroupTransformer, $credentials, $apiHost, new ArrayKeyedCache(), // getOneById() new ArrayKeyedCache(), // getOneByLegacyId() new ArrayKeyedCache(), // getMultipleByItemGroupId() $itemsResponseTransformer, new ArrayKeyedCache() // getItems() );
The registrars under src/Container/ (LeafTransformerServiceRegistrar, ComposedTransformerServiceRegistrar, ApiClientServiceRegistrar) are the canonical wiring if you need a reference.
⬆️ Upgrading to 2.0
Browse no longer builds anything itself: its constructor takes a PSR-11 container. The default
wiring moved into BrowseFactory, and the API host is now a required argument rather than an
optional fifth constructor argument.
// 1.x $browse = new Browse($clientId, $clientSecret, $marketplace, $store); $browse = new Browse($clientId, $clientSecret, $marketplace, $store, ApiHost::sandbox()); // 2.0 $browse = (new BrowseFactory(ApiHost::production()))->create($clientId, $clientSecret, $marketplace, $store); $browse = (new BrowseFactory(ApiHost::sandbox()))->create($clientId, $clientSecret, $marketplace, $store);
If you construct clients by hand, ItemApi now requires its ItemsResponseTransformerInterface and
getItems() cache, and ApiClientServiceRegistrar requires its getItems() cache, instead of
building defaults. CoreServiceRegistrar now takes the ApiClientInterface, a
ClientCredentialsTokenManagerFactoryInterface and a LockInterface; BrowseFactory supplies them. ItemTransformer takes eight part transformers instead of 29 nested ones.
This release also moves to christianjbrown/api-client 3, christianjbrown/oauth2-client 2.1 and
christianjbrown/key-value-store 3. The access-token store you pass to create() must be built for
key-value-store 3: new MemoryKeyValueStore(new NativeClock()) (it now needs a PSR-20 clock and
enforces TTLs), and FirestoreKeyValueStore takes a clock too. The package now requires psr/clock
and symfony/clock.
📝 Changelog
Notable changes in each release are listed in CHANGELOG.md.
📄 License
Released under the MIT License.