Metadata-Version: 2.4
Name: bidkit
Version: 0.1.1
Summary: A modern, typed Python SDK for the eBay REST APIs (unofficial).
Project-URL: Homepage, https://github.com/heyalexej/bidkit
Project-URL: Documentation, https://heyalexej.github.io/bidkit/
Project-URL: Repository, https://github.com/heyalexej/bidkit
Project-URL: Issues, https://github.com/heyalexej/bidkit/issues
Project-URL: Changelog, https://github.com/heyalexej/bidkit/blob/main/CHANGELOG.md
Author: bidkit contributors
License-Expression: MIT
License-File: LICENSE
License-File: NOTICE
Keywords: api,buy,commerce,ebay,marketplace,rest,sdk,sell
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: cryptography>=49.0.0
Requires-Dist: httpx>=0.27
Requires-Dist: orjson>=3.10
Requires-Dist: pydantic>=2.7
Provides-Extra: dev
Requires-Dist: datamodel-code-generator[http]>=0.66.3; extra == 'dev'
Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: ty>=0.0.56; extra == 'dev'
Provides-Extra: docs
Requires-Dist: mkdocs-gen-files>=0.5; extra == 'docs'
Requires-Dist: mkdocs-literate-nav>=0.6; extra == 'docs'
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=1.0.4; extra == 'docs'
Description-Content-Type: text/markdown

# bidkit

A modern, typed Python SDK for the eBay REST APIs — sync and async, generated from eBay's
OpenAPI contracts.

> **Unofficial.** This project is not affiliated with or endorsed by eBay Inc. "eBay" is a
> trademark of eBay Inc. See [NOTICE](NOTICE).

[![CI](https://github.com/heyalexej/bidkit/actions/workflows/ci.yml/badge.svg)](https://github.com/heyalexej/bidkit/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/bidkit?cacheSeconds=3600)](https://pypi.org/project/bidkit/)
[![Python](https://img.shields.io/pypi/pyversions/bidkit?cacheSeconds=3600)](https://pypi.org/project/bidkit/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

**Documentation: [heyalexej.github.io/bidkit](https://heyalexej.github.io/bidkit/)** — guides,
API reference, and the full generated reference for all 41 APIs.

- **41 eBay APIs, 455 typed operations** across `buy`, `commerce`, `developer`, `post_order`,
  and `sell` namespaces — one client: `client.buy.browse.search(q="...")`
- **Pydantic v2 models** for every request and response, generated from eBay's own OpenAPI
  contracts; unknown fields and new enum values never break validation
- **Sync and async** (`EbayClient` / `AsyncEbayClient`) on `httpx`, with `orjson` serialization
- **OAuth built in**: client-credentials and user tokens, automatic refresh with
  stampede-proof caching, authorization-code flow helpers
- **Automatic retries** for 429/transient 5xx with `Retry-After` support and full-jitter backoff
- **eBay digital signatures** (RFC 9421-style, Ed25519/RSA) for the Finances API
- **Fast imports**: lazy-loaded model modules — constructing a client costs tens of
  milliseconds, and you only pay for the services you use

Requires Python 3.11+.

## Installation

```bash
uv add bidkit          # or: pip install bidkit
```

## Quickstart

```python
from bidkit import EbayClient, EbayConfig

client = EbayClient(EbayConfig(app_id="...", cert_id="..."))  # or EbayClient.from_env()
results = client.buy.browse.search(q="vintage radio", limit=5)
for item in results.item_summaries or []:
    print(item.title, item.price.value if item.price else "?")
```

`EbayConfig.from_env()` reads `EBAY_APP_ID`, `EBAY_CERT_ID`, `EBAY_REFRESH_TOKEN`,
`EBAY_MARKETPLACE_ID`, `EBAY_SANDBOX`, and friends — see `bidkit.config` for the full list.

## Scope

bidkit covers eBay's **REST APIs** (Sell, Buy, Commerce, Developer, Post-Order). The legacy
XML APIs (Trading, Shopping, Finding) are out of scope by design — they predate OpenAPI and
cannot participate in the generated, typed architecture. If you need Trading API calls, the
community [`ebaysdk`](https://pypi.org/project/ebaysdk/) package (unmaintained since 2020,
but functional) can be used alongside.

## Authentication (eBay OAuth)

Application-scoped calls need only `app_id` + `cert_id` (the client-credentials grant is
handled automatically). To act on behalf of a seller you need a user `refresh_token`. The
authorization-code flow is a one-time, three-step exchange:

```python
client = EbayClient(EbayConfig(app_id="...", cert_id="...", ru_name="...", scopes=(...,)))

# 1. Send the user to this URL to grant consent.
print(client.authorization_url(state="..."))

# 2. eBay redirects to your RuName's accepted URL with ?code=<...>; grab that code.
# 3. Exchange it — this also authenticates the client immediately.
tokens = client.exchange_code(code)
print(tokens.refresh_token)  # persist this; pass it back as EbayConfig(refresh_token=...)
```

Only the **redirect capture** (step 2) needs the HTTPS "accepted URL" you registered for your
RuName in the eBay developer console — the exchange itself is a plain backend call. You can
capture the `code` with your own redirect handler, by copying it from the browser, or via
browser automation; the SDK only needs the resulting `code`. `AsyncEbayClient.exchange_code`
is the async equivalent.

`scripts/oauth_login.py` runs the whole flow. Interactively it opens the system browser and
prompts for the redirect URL; or pass the value as a flag to skip the prompt entirely:

```bash
# interactive: opens the browser, then paste the redirect URL at the prompt
uv run --extra dev scripts/oauth_login.py        # reads app/cert/ru/scopes from ebay-cli config

# two-step (no interactive prompt): print the URL, consent, then pass the redirect back
uv run --extra dev scripts/oauth_login.py --no-browser
uv run --extra dev scripts/oauth_login.py --redirect-url 'https://your-ru-url/?code=...'

# persist the result: writes refresh_token + expiries back into --config (other fields kept)
uv run --extra dev scripts/oauth_login.py --redirect-url '...' --write-config
```

The keyset environment is checked against `--sandbox`: a production App ID (`...-PRD-...`) used
with `--sandbox` fails eBay auth with `invalid_client`, so the script stops early and tells you
to drop `--sandbox` (sandbox needs a separate `...-SBX-...` keyset).

### Token caching

Access tokens are cached in memory by default, so every new process mints a fresh one. Pass a
`FileTokenCache` to persist tokens across runs (stored 0600 under `~/.cache/bidkit/`):

```python
from bidkit import EbayClient, FileTokenCache

client = EbayClient(config, token_cache=FileTokenCache())  # or FileTokenCache(path)
```

Any object implementing the two-method `TokenCache` protocol (`get`/`set`) works — e.g. a
Redis-backed cache for multi-host deployments.

## Pagination

`paginate` (and `paginate_async`) drive any list endpoint across pages and yield the individual
items, following eBay's `next` URL when present and falling back to `limit`/`offset` arithmetic
otherwise:

```python
from bidkit import paginate

for payout in paginate(client.sell.finances.get_payouts, limit=50):
    print(payout.payout_id)

# async
async for item in paginate_async(client.sell.inventory.get_inventory_items, limit=100):
    ...
```

Positional path params and query keywords are forwarded to the method; `offset`/`limit` are
managed for you. Use `max_items=N` to cap iteration, and `items_field="..."` to disambiguate
responses that carry more than one array.

## Retries & rate limiting

Transient responses are retried automatically. By default `429 Too Many Requests` and
transient `5xx` (500/502/503/504) are retried up to `max_retries` times with exponential
backoff + full jitter, honoring the `Retry-After` header when eBay sends one. Retries are
method-aware: idempotent methods (`GET`/`HEAD`/`OPTIONS`/`PUT`/`DELETE`) are replayed on both
429 and 5xx, while non-idempotent `POST` is replayed only on 429 (the request was rejected
before processing). Tune via `EbayConfig`:

```python
EbayConfig(
    max_retries=2,                       # 0 disables retries
    retry_statuses=(429, 500, 502, 503, 504),
    retry_backoff=0.5,                   # base seconds; delay = backoff * 2**attempt (jittered)
    retry_max_backoff=60.0,
    respect_retry_after=True,
)
```

For a scoped override, `client.with_options(max_retries=0, timeout=5.0)` returns a client
sharing the same connection pool and token cache with those fields changed.

### Call quota

eBay does not send quota headers on responses; remaining quota lives behind two Developer
Analytics lookups, which need **different token types**:

```python
# Application quota: requires an application token (client-credentials, base scope)
app = client.with_options(refresh_token=None, scopes=("https://api.ebay.com/oauth/api_scope",))
for rl in app.developer.analytics.get_rate_limits().rate_limits or []:
    ...

# Per-user quota: requires the user token (a client configured with refresh_token)
for rl in client.developer.analytics.get_user_rate_limits().rate_limits or []:
    ...
```

Tip: fetch unfiltered and filter client-side — the `api_context`/`api_name` server-side
filters are case-sensitive and unreliable, and eBay's payload mixes casings
(`"Sell"`, `"commerce"`, `"TradingAPI"`).

## Logging

bidkit is silent by default and logs through the standard library under the `bidkit`
namespace, so it composes with whatever your application uses (plain `logging`, structlog,
JSON formatters, OpenTelemetry handlers). Opt in per subsystem:

```python
import logging

logging.basicConfig(level=logging.INFO)
logging.getLogger("bidkit").setLevel(logging.DEBUG)   # or just "bidkit.retry"
```

```
DEBUG:bidkit.transport:getPayouts GET https://apiz.ebay.com/sell/finances/v1/payout -> 200 (312 ms)
INFO:bidkit.auth:refreshed user token for refresh:1a2b3c4d… (expires in 7200 s)
WARNING:bidkit.retry:getOrders attempt 1/3: HTTP 429, retrying in 1.8 s (Retry-After)
```

Levels: requests at `DEBUG` (`bidkit.transport`), token acquisition at `INFO`
(`bidkit.auth`), retries at `WARNING` (`bidkit.retry`); failures raise exceptions instead of
being logged twice. Every record also carries structured fields (`operation`, `method`,
`status`, `elapsed_ms`, `attempt`, `delay_s`, …) for JSON/structured formatters. Secrets —
tokens, Authorization headers, request bodies — are never logged. For wire-level detail,
enable the `httpx`/`httpcore` loggers; for tracing, the OpenTelemetry httpx instrumentation
works out of the box since bidkit rides on httpx.

## Digital signatures (Finances API)

The Finances API and several refund operations (Fulfillment `issueRefund`, the Post-Order
issue-refund calls) reject requests unless they carry an RFC 9421-style HTTP message
signature (`x-ebay-signature-key` + `Signature` headers). Provide signing material via
`EbaySigningConfig` and exactly those operations are signed automatically — other APIs are
left untouched, since eBay does not expect signatures there. If eBay expands the signed list
before the SDK catches up, `EbaySigningConfig(..., sign_all=True)` signs every request:

```python
from bidkit import EbayClient, EbayConfig, EbaySigningConfig

client = EbayClient(EbayConfig(
    refresh_token="...",
    signing=EbaySigningConfig(jwe="<jwe>", private_key="<pem>"),  # or .from_key_file(path)
))
client.sell.finances.get_payouts(limit=3)  # signed; returns 200 instead of 403
```

The `jwe` and key come from the Key Management API; Ed25519 (eBay's default) and RSA keys are
supported. `from_env` also reads `EBAY_SIGNING_KEY_FILE` or
`EBAY_SIGNING_JWE` + `EBAY_SIGNING_PRIVATE_KEY`.

## Verifying eBay push notifications

Production apps must expose a notification endpoint (at minimum for the mandatory
marketplace-account-deletion topic). bidkit verifies eBay's signatures and answers the
endpoint-validation challenge:

```python
from bidkit import EbayClient, NotificationVerifier, challenge_response

verifier = NotificationVerifier(client)   # app credentials suffice; keys are cached ~1 h

# GET ?challenge_code=...  ->  200, application/json
challenge_response(challenge_code, VERIFICATION_TOKEN, "https://your.app/ebay/notifications")

# POST (notification delivery): verify the RAW body before parsing it
if verifier.verify(raw_body_bytes, request.headers["x-ebay-signature"]):
    ...  # handle, respond 204
else:
    ...  # respond 412; eBay will retry
```

`AsyncNotificationVerifier` is the drop-in for async frameworks. `verify` returns `False`
for bad signatures and raises only on operational failures (key fetch, unsupported key) —
respond `500` for those so eBay retries later.

## Supported APIs

All **41 eBay APIs** below are generated and wired into the client across **5 namespaces**
(`buy`, `commerce`, `developer`, `post_order`, `sell`), exposing **455 typed operations** with
both sync (`EbayClient`) and async (`AsyncEbayClient`) surfaces. Versions are pinned to the
bundled OpenAPI specs in `specs/ebay/`. "Ops" counts callable operations (each also has a
`raw_response` overload and binary downloads add a `stream_*` variant).

### Buy — `client.buy` (29 ops)

| Accessor | API | Version | Ops | Maturity |
|---|---|---|---|---|
| `buy.browse` | Browse | `v1.20.4` | 7 | Stable |
| `buy.deal` | Deal | `v1.3.0` | 4 | Stable |
| `buy.feed` | Feed (Item Feed) | `v1_beta.35.3` | 4 | Beta |
| `buy.marketing` | Marketing | `1.1.0` | 3 | Stable |
| `buy.marketplace_insights` | Marketplace Insights | `v1_beta.2.0` | 1 | Beta |
| `buy.offer` | Offer | `v1_beta.0.1` | 2 | Beta |
| `buy.order` | Order | `v2.1.4` | 8 | Stable |

### Commerce — `client.commerce` (64 ops)

| Accessor | API | Version | Ops | Maturity |
|---|---|---|---|---|
| `commerce.catalog` | Catalog | `v1_beta.5.3` | 2 | Beta |
| `commerce.charity` | Charity | `v1.2.1` | 2 | Stable |
| `commerce.feedback` | Feedback | `v1.0.0` | 5 | Stable |
| `commerce.identity` | Identity | `v2.0.0` | 1 | Stable |
| `commerce.media` | Media | `v1_beta.5.1` | 13 | Beta |
| `commerce.message` | Message (M2M) | `1.0.0` | 5 | Stable |
| `commerce.notification` | Notification | `v1.6.7` | 21 | Stable |
| `commerce.taxonomy` | Taxonomy | `v1.1.1` | 9 | Stable |
| `commerce.translation` | Translation | `v1_beta.1.6` | 1 | Beta |
| `commerce.vero` | VeRO | `1.0.0` | 5 | Stable |

### Developer — `client.developer` (6 ops)

| Accessor | API | Version | Ops | Maturity |
|---|---|---|---|---|
| `developer.analytics` | Analytics | `v1_beta.0.1` | 2 | Beta |
| `developer.client_registration` | Client Registration | `v1.0.0` | 1 | Stable |
| `developer.key_management` | Key Management | `v1.0.0` | 3 | Stable |

### Post-Order — `client.post_order` (58 ops)

| Accessor | API | Version | Ops | Maturity |
|---|---|---|---|---|
| `post_order.cancellation` | Cancellation | `v2` * | 7 | Stable |
| `post_order.case` | Case Management | `v2` * | 7 | Stable |
| `post_order.inquiry` | Inquiry | `v2` * | 11 | Stable |
| `post_order.return_` | Return | `v2` * | 33 | Stable |

\* Post-Order specs carry `info.version` `0.1`, but the API is served at `/post-order/v2`.

### Sell — `client.sell` (298 ops)

| Accessor | API | Version | Ops | Maturity |
|---|---|---|---|---|
| `sell.account` | Account v1 | `v1.9.3` | 37 | Stable |
| `sell.account_v2` | Account v2 | `2.2.0` | 14 | Stable |
| `sell.analytics` | Analytics | `1.3.2` | 4 | Stable |
| `sell.compliance` | Compliance | `1.4.1` | 3 | Stable |
| `sell.edelivery_international_shipping` | eDelivery Intl Shipping (EDIS) | `1.1.0` | 27 | Stable |
| `sell.feed` | Feed | `v1.3.1` | 23 | Stable |
| `sell.finances` | Finances | `v1.19.0` | 11 | Stable † |
| `sell.fulfillment` | Fulfillment | `v1.20.6` | 15 | Stable |
| `sell.inventory` | Inventory | `1.18.5` | 36 | Stable |
| `sell.leads` | Classified Leads | `v1.0.0` | 2 | Stable |
| `sell.listing` | Listing | `v1_beta.2.1` | 1 | Beta |
| `sell.logistics` | Logistics | `v1_beta.0.0` | 6 | Beta |
| `sell.marketing` | Marketing | `v1.22.4` | 80 | Stable |
| `sell.metadata` | Metadata | `v1.13.0` | 28 | Stable |
| `sell.negotiation` | Negotiation | `v1.1.2` | 2 | Stable |
| `sell.recommendation` | Recommendation | `v1.1.0` | 1 | Stable |
| `sell.stores` | Store | `1` | 8 | Stable |

† Requires a digital signature — see [Digital signatures](#digital-signatures-finances-api).

## Performance

The generated layer is large (40+ model modules), so importing all of it eagerly would make
client construction slow. Instead, model modules are **lazy-loaded**: each `<service>_models`
alias in the generated resources is a proxy that imports its module only when a method of that
service is first called. Constructing a client therefore loads **no** model modules, and you
only ever pay for the services you actually use — calling `client.buy.browse.*` never imports
the (much larger) marketing or metadata models.

Generated models also set `defer_build=True`, so Pydantic compiles a model's validators on its
first `model_validate(...)` rather than at import time. Combined, this takes first client
construction from well over a second down to tens of milliseconds, with the remaining per-model
cost amortized across first use. Static typing is unaffected — type checkers still resolve the
real model types via a `TYPE_CHECKING` import block.

## Development

Raw eBay OpenAPI specs are copied into `specs/ebay`. The generator preprocesses those specs
with explicit eBay compatibility patches, writes normalized specs to `specs/normalized`
(a git-ignored intermediate, never packaged), generates models with
`datamodel-code-generator`, and keeps the HTTP resource surface generated by the local script.

```bash
uv run --extra dev scripts/generate_openapi.py   # regenerate clients from specs
uv run --extra dev ruff check .                  # lint
uv run --extra dev ty check src tests            # type check
uv run --extra dev pytest                        # tests
```

The bundled scripts (`scripts/oauth_login.py` and the maintainer smoke scripts) read
credentials from an ebay-cli style config; see [`examples/`](examples/) for templates.

`~/.config/ebay-cli/config.json`:

| Field (`credentials.*`) | Used for | Notes |
|---|---|---|
| `app_id` | OAuth + all calls | aka `client_id`; the keyset encodes the env (`-PRD-`/`-SBX-`) |
| `cert_id` | OAuth + all calls | aka `client_secret` |
| `ru_name` | code exchange | aka `redirect_uri`; the registered RuName |
| `refresh_token` | calling as a seller | mint it with `oauth_login.py` |
| `granted_scopes` | OAuth + scopes | aka `scopes`; list of scope URLs |
| `dev_id` | optional | |

Top-level `environment` and `marketplace_default` are convenience hints. `~/.config/ebay-cli/
signing-key.json` (`jwe` + `privateKeyPem`, optional `cipher`) feeds the Finances signing layer
and maps to `EbaySigningConfig.from_key_file(...)`.

`EbayConfig.from_file()` loads this format directly (aliases, `environment`,
`marketplace_default`, and a sibling `signing-key.json` included):

```python
client = EbayClient(EbayConfig.from_file())   # ~/.config/ebay-cli/config.json
```

## License

MIT — see [LICENSE](LICENSE). The eBay OpenAPI contract files under `specs/` are © eBay Inc.,
provided under the [eBay API License Agreement](https://developer.ebay.com/join/api-license-agreement),
and are not covered by the MIT grant; see [NOTICE](NOTICE).
