Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sanbuk

Official Python SDK for Sanbuk — CPA conversion tracking.

راهنمای فارسی →

pip install sanbuk
from 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.

Framework guides

Guide Covers
Django middleware, signals, Celery, settings
Flask before_request, blueprints, RQ
FastAPI dependencies, BackgroundTasks, the async client

How it works

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.

Requirements

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.

Capturing the click id

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.

Choosing an event id

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.

Results

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_id

Rehearsing before you go live

result = sanbuk.test().postback(action="purchase", event_id="ORD-1", value=1000)

result.is_test  # False here means the sandbox header did not land

A 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.

Errors

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.

Retries

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.

Async

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.

Bringing your own transport

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.

Development

python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
mypy

Fully type-annotated and ships py.typed, so mypy and Pyright see the real types.

License

Apache-2.0

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages