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.
Billing (prepaid subscriptions)
Yoon 0.3.0 bills prepaid subscriptions: each period is an invoice the customer pays through
hosted checkout; nothing renews by itself. yoon.customers(), yoon.plans() and
yoon.subscriptions() (which also has get_invoice and list_invoices) are the generated
APIs; wrap calls in yoon.call(...) to get YoonException.
from yoonpay.billing import Entitlement, changes_entitlement
from yoonpay.fastapi import NoCheckout, redirect_to_checkout
from yoonpay.generated import CreateSubscriptionRequest, RenewSubscriptionRequest
# 1. Subscribe (customer created once with external_id = your user id), then send to checkout.
sub = yoon.call(lambda: yoon.subscriptions().create_subscription(
f"subscribe-{user.id}-{price_id}",
CreateSubscriptionRequest(customer=customer_id, price=price_id, return_url="https://app.example/billing")))
return redirect_to_checkout(sub) # 303 to sub.latest_invoice.checkout_url
# Renewing: an invoice that is paid, void or whose checkout expired has no checkout_url,
# so redirect_to_checkout raises NoCheckout; renew gives an open invoice with a fresh checkout.
sub = yoon.call(lambda: yoon.subscriptions().renew_subscription(
sub_id, f"renew-{sub_id}-{period}", RenewSubscriptionRequest(return_url="https://app.example/billing")))
return redirect_to_checkout(sub)
# 2. Entitle only from webhooks (never from the return URL).
@webhooks.post("/yoon/webhook", status_code=204)
def yoon_events(event: Event = Depends(yoon_event)) -> None:
if changes_entitlement(event.type): # subscription.created|activated|renewed|changed|past_due|expired|canceled
e = Entitlement.from_subscription(event.object)
save_access(e.external_id, entitled=e.entitled, plan=e.plan, until=e.until, features=e.features)
Customer portal (Yoon 0.6+): return redirect_to_portal(yoon, customer_id, return_url=..., allow={"cancel": True}) in a FastAPI route, or yoon.create_portal_session(...) anywhere — for the signed-in user's own customer only.
Entitlement is entitled for active, past_due (the grace period) and trialing; until is
current_period_end, None for an open-ended comp. An unknown status gives
entitled=False and an unknown event type is not in ENTITLEMENT_EVENTS: neither raises.
INFORMATIONAL_EVENTS (subscription.renewal_upcoming, subscription.trial_will_end,
invoice.created|paid|voided) never change access. yoonpay.billing needs no framework; only
redirect_to_checkout lives in yoonpay.fastapi (checkout_url(obj) gives the URL anywhere).
Prompt for an AI coding agent
Copy this into your coding agent (Claude Code, Cursor, Copilot) to add Yoon to an existing app.
Integrate Yoon payments (yoonpay 0.7.x) into this Python app.
Install: pip install "yoonpay[django]" (or [fastapi], [flask]; Python 3.10+)
Do:
1. One shared client: Yoon(os.environ["YOON_URL"], os.environ["YOON_API_KEY"]).
2. Create a payment keyed on the order: yoon.create_payment({...amount, "currency": "XOF",
"country": "SN", "method": "wave", "reference": str(order.id), "return_url": ...},
idempotency_key=f"order-{order.id}"); redirect to its checkout_url.
3. Webhook with this app's framework helper (it verifies the raw body and drops duplicates):
Django: @yoon_webhook from yoonpay.django (settings.YOON_WEBHOOK_SECRET); FastAPI:
APIRouter(route_class=yoon_webhook_route(secret=...)) and a yoon_event dependency from
yoonpay.fastapi; Flask: @yoon_webhook from yoonpay.flask (app.config["YOON_WEBHOOK_SECRET"]).
Update the order from the event's type and object.
Subscriptions (Yoon 0.3.0+, prepaid): create with yoon.subscriptions().create_subscription(key,
CreateSubscriptionRequest(...)) inside yoon.call(...), redirect with redirect_to_checkout(sub)
from yoonpay.fastapi (or to sub.latest_invoice.checkout_url); grant access only in the webhook:
if changes_entitlement(event.type): Entitlement.from_subscription(event.object) (yoonpay.billing).
past_due is still entitled (grace); unknown statuses are not entitled.
4. Errors are YoonException: problem_code, http_status, is_retryable().
Rules (they protect real money; follow them exactly):
- Amounts are integers in minor units, never floats: XOF has no minor unit, 5000 = 5 000 XOF.
- Every write takes an idempotency key tied to the business object ("order-<id>",
"refund-<order id>-<n>"). On a retryable error, retry with the SAME key; a new key could
charge twice. The client never retries on its own.
- Store Yoon's payment id on the order. Fulfil ONLY when the webhook says payment.succeeded
(or GET the payment and check status == "succeeded"). The customer returning to return_url
proves nothing.
- "pending" means the provider has not answered yet, not failed: show "waiting for payment".
payment.failed / payment.expired: let the customer try again (a new order or a new key).
- Webhooks: verify the signature over the RAW request body (the helper below does it), answer
2xx quickly, and make the handler idempotent: the same event can arrive more than once.
Events: payment.succeeded, payment.failed, payment.expired, refund.succeeded, refund.failed,
payout.paid, payout.failed, payout.needs_review; billing: subscription.* and invoice.*.
- Never log the API key or full phone numbers; read YOON_URL, YOON_API_KEY and
YOON_WEBHOOK_SECRET from the environment.
- Optional: "checkout": "hosted" (without "method") sends the customer to Yoon's own page to
choose Wave, Orange Money, card, …; redirect them to the returned checkout_url.
Verify: run Yoon with YOON_DEMO_ENABLED=true and YOON_APPS_<APP>_PROVIDERS_DEMO_PRIORITY=1,
set YOON_APPS_<APP>_WEBHOOK_URL to this app's webhook URL, create a payment, open its
checkout_url, click Pay, and check the order becomes paid only after the webhook arrives.
Then click Decline on another payment and check the order is not paid.
Docs: https://yoonpay.benhattab.pro/docs/
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.7.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.7.0.tar.gz | 108.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| yoonpay-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 422.0 kB
Release files / yoonpay-0.7.0.tar.gz
| Download URL | yoonpay-0.7.0.tar.gz |
|---|---|
| Size | 108.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
737a27a2690b53f00823e41317db36d00430376e4f9a9a8f7b1f4c821715422f
|
|
BLAKE2b-256 checksum How to use checksums |
e9142963acb9509109d901c37d89e5f258732a76b5b49adffd1db10aa50f1700
|
| 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 16, 2026.
Transparency logRelease files / yoonpay-0.7.0-py3-none-any.whl
| Download URL | yoonpay-0.7.0-py3-none-any.whl |
|---|---|
| Size | 313.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
275d54e200f303ce2a447c6b783d8b5aa78046c7d3a437a3e2fca463a694f803
|
|
BLAKE2b-256 checksum How to use checksums |
c9b2bc38f2481b7b191e54c77cc397dcb9c805e80d3f372562b3d6a55fd6e583
|
| 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 16, 2026.
Transparency log