Official Python SDK for Sanbuk — CPA conversion tracking.
pip install sanbukfrom sanbuk import Sanbuk
sanbuk = Sanbuk(api_key=os.environ["SANBUK_API_KEY"])
sanbuk.postback(
action="purchase",
event_id=order.id, # your own id, never reused
click_id=order.sanbuk_click_id,
value=25_000_000, # Rial
)That is the whole integration. Everything below is detail.
| Guide | Covers |
|---|---|
| Django | middleware, signals, Celery, settings |
| Flask | before_request, blueprints, RQ |
| FastAPI | dependencies, BackgroundTasks, the async client |
Two channels report the same conversion, carrying the same event_id:
- Postback (this SDK) is the financial source of truth. Only a postback can create a payable conversion.
- Pixel (the browser) is verification and anti-fraud. It never bills on its own.
Sanbuk pairs them inside a 48-hour window. Getting that pairing right is the only thing the integration asks of you.
Python 3.9 or newer. No required dependencies — the default transport is urllib from the standard library, so installing this cannot conflict with the HTTP library your project already pins.
Sanbuk adds ?snbk_cid=<uuid> to your landing URL, and the browser pixel copies it into a 30-day cookie. Store it against the order the moment you see it — by the time the order is paid, the customer may be on a payment gateway or gone.
from sanbuk import capture, click_id_from_url, click_id_from_cookies
click_id = capture(request.get_full_path(), request.COOKIES)
# or individually
click_id = click_id_from_url(request.get_full_path())
click_id = click_id_from_cookies(request.COOKIES)The URL wins over the cookie: a returning visitor arriving on a new click belongs to that click, not the one still in their cookie jar.
Malformed values return None rather than being passed through, so junk in a query string cannot become a 422 at checkout.
Use something you already own and never reuse. An order id is ideal.
The intake is idempotent per (action, event_id) within a mode: a repeat answers 200 and changes nothing. That is what makes every retry safe — and why you should not generate a fresh UUID per attempt. Doing so defeats deduplication and could bill twice.
result = sanbuk.postback(action="purchase", event_id=order.id, value=total)
result.accepted # first time
result.duplicate # already seen — not an error
result.status # "accepted" | "duplicate"
result.mode # what actually answered
result.event_idresult = sanbuk.test().postback(action="purchase", event_id="ORD-1", value=1000)
result.is_test # False here means the sandbox header did not landA test event verifies your setup in the panel but never spends your wallet, and is a separate event from its live twin — so rehearsing with a real order id never consumes it.
Check result.mode rather than trusting what you sent. It is the only way to notice you are rehearsing with real money.
from sanbuk import (
SanbukApiError,
SanbukConfigError,
SanbukError,
SanbukNetworkError,
SanbukRateLimitError,
UnknownActionError,
ValidationFailedError,
ValueRequiredError,
)
try:
sanbuk.postback(action="purchase", event_id=order.id, value=total)
except (SanbukRateLimitError, SanbukNetworkError):
queue_for_later(order.id) # transient — the same event_id is safe to resend
except ValidationFailedError as exc:
log.error("payload rejected: %s", exc.errors)
except SanbukApiError as exc:
log.error("sanbuk refused: %s", exc.code)
except SanbukConfigError:
log.exception("called incorrectly; nothing left the process")The rule worth internalising: transient errors go back on the queue, payload errors go to a human. Retrying validation_failed forever just burns rate limit.
Branch on exc.code, never on the message — it is localised and may change.
Network failures, 429 and 5xx are retried with exponential backoff and full jitter. Refusals are never retried: a wrong key will be just as wrong in 200ms.
Sanbuk(
api_key=key,
timeout=5.0,
max_retries=3,
retry_base_delay=0.2,
retry_max_delay=5.0,
)If you already have a durable queue, max_retries=0 is reasonable — let the queue own the retry policy instead of blocking a worker for seconds.
pip install "sanbuk[async]"from sanbuk import AsyncSanbuk
async with AsyncSanbuk(api_key=key) as sanbuk:
await sanbuk.postback(action="purchase", event_id=order.id, value=total)Same arguments, same errors, same retry behaviour. It is an optional extra because most conversions are reported from a request handler or a worker that is perfectly happy synchronous, and httpx is not worth forcing on every install.
from sanbuk import HttpClient, Response, Sanbuk
class MyHttpClient:
def post(self, url: str, body: str, headers) -> Response:
...
sanbuk = Sanbuk(api_key=key, http_client=MyHttpClient())Retries stay in the client, so every transport retries identically. This is also the seam to use in tests — see the framework guides.
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
mypyFully type-annotated and ships py.typed, so mypy and Pyright see the real types.
Apache-2.0