yoonpay
Python client for Yoon, a self-hosted payment gateway for African payment providers, with Django, FastAPI and Flask helpers. Apache-2.0.
Requires Python 3.10+. Synchronous (urllib3); there is no async client yet.
pip install yoonpay # the client
pip install "yoonpay[django]" # or [fastapi], [flask]: the framework is an optional extra
Plain Python
import os
from yoonpay import Yoon, YoonException
yoon = Yoon("https://pay.example.com", os.environ["YOON_API_KEY"]) # timeout=30, connect_timeout=5
try:
payment = yoon.create_payment(
{
"amount": 5000, # XOF has no minor unit: 5 000 FCFA
"currency": "XOF",
"country": "SN",
"method": "wave", # wave, orange_money, free_money, card
"customer": {"phone": "+221771234567"},
"reference": "order_1042",
"return_url": "https://shop.example/orders/1042",
},
idempotency_key="order-1042", # tie it to your order: a retry can never charge twice
)
redirect_to = payment.checkout_url
except YoonException as e:
e.problem_code # e.g. no_provider_for_method, refund_exceeds_payment
e.is_retryable() # True: retry with the same idempotency key
Helpers: create_payment, get_payment, refund(payment_id, idempotency_key, amount=None, reason=None), create_payout, export_csv("payments" | "refunds" | "payouts" | "ledger", from_=None, to=None). Every other operation of the API is on the generated API objects —
yoon.payments(), .refunds(), .payouts(), .events(), .ledger(), .meta() — wrapped
with yoon.call(lambda: …) to turn errors into YoonException:
page = yoon.call(lambda: yoon.payments().list_payments(reference="order_1042"))
The idempotency key is a required argument of every write: the client never makes one up and never retries on its own (urllib3's retries are switched off). Retrying is your decision, with the same key.
Django
# settings.py
YOON_WEBHOOK_SECRET = os.environ["YOON_WEBHOOK_SECRET"] # YOON_APPS_<APP>_WEBHOOK_SECRET on the Yoon side
# optional: YOON_WEBHOOK_CACHE = "default", YOON_WEBHOOK_DEDUPE_SECONDS = 259200
# views.py
from django.http import HttpResponse
from yoonpay.django import yoon_webhook
@yoon_webhook
def yoon_events(request, event):
if event.type == "payment.succeeded":
Order.objects.filter(yoon_payment_id=event.object["id"]).update(status="paid")
return HttpResponse(status=204)
# urls.py
urlpatterns = [path("yoon/webhook", yoon_events)]
The decorator exempts the view from CSRF, accepts only POST, and remembers handled event ids in Django's cache (use a shared backend such as Redis when you run several servers).
FastAPI
import os
from fastapi import APIRouter, Depends, FastAPI
from yoonpay import Event
from yoonpay.fastapi import yoon_event, yoon_webhook_route
webhooks = APIRouter(route_class=yoon_webhook_route(secret=os.environ["YOON_WEBHOOK_SECRET"]))
@webhooks.post("/yoon/webhook", status_code=204)
def yoon_events(event: Event = Depends(yoon_event)) -> None:
if event.type == "payment.succeeded":
...
app = FastAPI()
app.include_router(webhooks)
Only the routes of that router are checked. The default id store is in-process; with several
workers pass store=CacheEventStore(shared_cache).
Flask
from yoonpay.flask import yoon_webhook
app.config["YOON_WEBHOOK_SECRET"] = os.environ["YOON_WEBHOOK_SECRET"]
@app.post("/yoon/webhook")
@yoon_webhook
def yoon_events(event):
if event.type == "payment.succeeded":
...
return "", 204
The default id store is in-process, per app; with several workers pass
@yoon_webhook(store=CacheEventStore(cache)), e.g. over Flask-Caching.
Webhooks
All three helpers behave the same: a wrong or stale signature (more than 300 s off) gets 401
without reaching your code, an already-handled event gets 200 without reaching your code, and an
event id is remembered only after your code answered 2xx — if it fails or raises, Yoon's retry
reaches it again. Events are unordered and may arrive more than once: act on the state in
event.object, not on arrival order.
Raw body. The signature covers the bytes Yoon sent. The helpers read the raw body
(request.body in Django, await request.body() in FastAPI, request.get_data() in Flask);
if you verify by hand, do the same — a body parsed and re-encoded as JSON no longer matches
and is rejected.
Outside these frameworks:
from yoonpay import Event, verify_signature
if not verify_signature(secret, headers.get("Yoon-Signature"), raw_body):
... # answer 401
event = Event.from_json(raw_body) # event.id, event.type, event.object
sign_signature(secret, raw_body) builds a header for your own tests.
Errors
YoonException carries http_status (None when Yoon was not reached), problem_code (Yoon's
stable code, None when Yoon was not reached), problem (the full problem document) and
is_retryable() — true when Yoon was unreachable, busy (idempotency_in_progress) or failing
(5xx). A 2xx answer the client cannot read gets problem_code = "unreadable_response" and is
not retryable: the call may have worked, look it up first. The API key never appears in
errors or in repr(yoon).
A status or event type added by a newer Yoon server does not break parsing: the value is kept
as a string (payment.status == "new_status").
Layout
generated/— generated fromapi/openapi.yamlbyclients/generate.sh(the packageyoonpay.generated). Never edit by hand; CI fails if it drifts from the contract.src/yoonpay/— the hand-written layer:Yoon,YoonException, webhook helpers, framework integrations.e2e/run.py— the shared end-to-end scenario (clients/e2e/scenario.md) against a real server.
pip install -e ".[django,fastapi,flask,test]" && pytest
Metadata
Release files for yoonpay 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| yoonpay-0.1.0.tar.gz | 62.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| yoonpay-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 203.5 kB
Release files / yoonpay-0.1.0.tar.gz
| Download URL | yoonpay-0.1.0.tar.gz |
|---|---|
| Size | 62.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d82258b4e5aef166a161c24aca5d0a5c1b23dae734bd24cc43b6dc608077e7ce
|
|
BLAKE2b-256 checksum How to use checksums |
420f8cb948ac31b71a5e9ef965d8dc7295291021183b18542e8c19b2bde3b060
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / yoonpay-0.1.0-py3-none-any.whl
| Download URL | yoonpay-0.1.0-py3-none-any.whl |
|---|---|
| Size | 141.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c5ce81ae747261eb5bf5f49ce6161a9f334e7cd7de600878752dcc29d5ce7ad6
|
|
BLAKE2b-256 checksum How to use checksums |
61c7d2fd3d2ec655c3863c9b3d527c97838b85420dcd7849d7d3453be7746840
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log