Metadata-Version: 2.4
Name: ridebuilder-affiliate
Version: 0.1.1
Summary: Server-side SDK for RideBuilder FirstParty affiliate tracking: capture click_id and send checkout/return postbacks.
Project-URL: Repository, https://github.com/RideBuilder/affiliate-python
Author: RideBuilder
License: MIT
License-File: LICENSE
Keywords: affiliate,attribution,postback,ridebuilder
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: async
Requires-Dist: httpx>=0.24; extra == 'async'
Provides-Extra: dev
Requires-Dist: httpx>=0.24; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# ridebuilder-affiliate (Python)

Server-side SDK for RideBuilder's FirstParty affiliate program. It does two things:

1. **Capture** the `click_id` a shopper arrives with, so your backend can bind it to the cart/order.
2. **Report** checkout and return postbacks to RideBuilder (auth, retries, idempotency handled).

Mirrors the Node reference SDK (`@ridebuilder/affiliate`) and the .NET SDK — same contract, verified by
the shared [conformance suite](../conformance).

## Install

```bash
pip install ridebuilder-affiliate            # sync client, zero third-party dependencies
pip install ridebuilder-affiliate[async]     # adds httpx for the async client
```

Requires Python 3.9+. The sync client uses only the standard library; `httpx` is needed only for the
async client (`AsyncRideBuilderClient`).

## The pattern: capture at landing, bind to the order

The `click_id` only reliably reaches checkout if you take it out of the browser early and put it on the
cart/order. Capture it on landing, store it with the cart, and send it at purchase.

```python
from decimal import Decimal
from ridebuilder_affiliate import RideBuilderClient, click_id_from_url

# 1. On landing, read a validated click_id off the request URL and persist it onto YOUR cart record.
click_id = click_id_from_url(str(request.url))
if click_id:
    cart.ridebuilder_click_id = click_id

# 2. At order time, send the postback from your backend.
rb = RideBuilderClient(api_key=os.environ["RIDEBUILDER_API_KEY"])
rb.report_checkout(
    order_id=order.id,
    subtotal=Decimal("199.99"),   # major units
    currency="USD",
    click_id=order.ridebuilder_click_id,
)
```

Store the API key server-side (env/secrets) — never in frontend code.

### Async (FastAPI / Starlette / Litestar)

```python
from ridebuilder_affiliate import AsyncRideBuilderClient

rb = AsyncRideBuilderClient(api_key=os.environ["RIDEBUILDER_API_KEY"])
await rb.report_checkout(order_id=order.id, subtotal=Decimal("199.99"), currency="USD", click_id=cid)
```

## Decoupled frontend (e.g. React) + separate Python backend

If your frontend is separate from your API, the landing request never hits your backend, so there is
nothing to capture server-side. Instead the browser **snippet** captures the `click_id` into a
first-party cookie, and you get it to your backend one of two ways:

**Same registrable domain** — the cookie rides along; read it off the `Cookie` header:

```python
from ridebuilder_affiliate import click_id_from_cookie_header

click_id = click_id_from_cookie_header(request.headers.get("cookie"))
```

**Cross-domain / mobile** — the frontend forwards it (e.g. `window.RideBuilder.getAttribution().click_id`)
in the checkout call, as a header or body field:

```python
from ridebuilder_affiliate import click_id_from_headers

click_id = click_id_from_headers(request.headers)   # default header: X-RideBuilder-Click-Id
```

Either way, the reporting call (`report_checkout`) is unchanged — that's the SDK's real value in a
decoupled setup.

### Refunds

```python
rb.report_return(return_id=refund.id, order_id=order.id, refund_amount=Decimal("49.95"), currency="USD")
```

## Integration protocol (register / verify / heartbeat)

Pass `environment` when constructing the client (`"production"` default, or `"sandbox"` for test traffic):

```python
rb = RideBuilderClient(api_key=api_key, environment="production")

integration = rb.register()   # handshake on install/startup; returns a stable integration id
rb.verify()                   # deploy/CI self-test — raises RideBuilderError on a bad/rotated key
rb.heartbeat()                # periodic liveness
```

On a **long-running process**, let the SDK heartbeat for you (default hourly); stop it on shutdown:

```python
rb.start_heartbeat()          # fires one now, then hourly (async: same call on AsyncRideBuilderClient)
# ...on shutdown:
rb.stop_heartbeat()
```

In **serverless** (no persistent process), schedule `heartbeat()` from an external trigger instead — or
rely on your normal postback traffic, which already proves you're alive. The SDK reports its own `type`
(`python_sdk`), `version`, and default capabilities.

## Capture helpers

All validate `ref == "ridebuilder"` and the UUID-v4 `click_id`, returning `None` otherwise — the same
rules the browser snippet enforces. None of them persist the id; bind it to your cart/order yourself.

- `click_id_from_url(url)` — from an absolute or relative URL.
- `click_id_from_query(query)` — from a decoded query map (framework query params).
- `click_id_from_cookie_header(cookie_header)` — recover it from the `ridebuilder_attribution` cookie.
- `click_id_from_headers(headers, name="X-RideBuilder-Click-Id")` — from a forwarding header (decoupled path).

## Client options

`RideBuilderClient(api_key, *, base_url=None, max_retries=3, timeout=10.0, environment="production", transport=None)`

- `base_url` — defaults to `https://api.ridebuilder.com/v1`.
- `max_retries` — retries on network errors, timeouts, 5xx, and 429 (default 3). Safe: `order_id`/`return_id`
  are idempotency keys server-side, so nothing double-counts.
- `timeout` — per-attempt timeout in seconds (default 10).
- `transport` — inject a custom transport (for tests, or a non-httpx async backend).

`report_checkout` / `report_return` return `PostbackResult(accepted, status)` (`202` = accepted). A `202`
means **received, not yet validated** — RideBuilder validates asynchronously. Invalid input raises a
non-retryable `RideBuilderError`; auth/size failures (`401`, `413`) raise with `.status` and `.error_code`.
Amounts (`Decimal` preferred) must be `> 0` with at most 2 decimal places, or the call raises up front.

## Tests

Unit + conformance tests are standard-library `unittest` (no third-party deps). From `sdk/python`:

```bash
PYTHONPATH=src python -m unittest discover -s tests -t tests
```

For an end-to-end check against a running stack — the real attribution→commission funnel, driven by a real
click — run the Python storefront backend (`backends/python`) in [tools/mock-retailer](../../tools/mock-retailer).

## Contract

Wraps the RideBuilder affiliate REST contract — `POST /v1/postback/checkout`, `POST /v1/postback/return`,
`POST /v1/postback/health`, the `/integration/*` endpoints, the `/redirect` link format, and API-key
provisioning. Verified byte-for-byte against the Node and .NET SDKs by the shared conformance fixtures.
