Skip to main content

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.6.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 from api/openapi.yaml by clients/generate.sh (the package yoonpay.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.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for yoonpay 0.6.0
File Size Uploaded
yoonpay-0.6.0.tar.gz 108.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for yoonpay 0.6.0
File Interpreter ABI Platform
yoonpay-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 422.1 kB

Release files / yoonpay-0.6.0.tar.gz

Download URL yoonpay-0.6.0.tar.gz
Size 108.5 kB
Tags Source
SHA-256 checksum
How to use checksums
541e36915f4537c2f0642ef221418c75be29f06350a07891f0a106ce711b9b52
BLAKE2b-256 checksum
How to use checksums
c3cb50c2e8a00517d7f6efe390488abef9994813dfddf78667b1fc711752c7f3
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

Release files / yoonpay-0.6.0-py3-none-any.whl

Download URL yoonpay-0.6.0-py3-none-any.whl
Size 313.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ce84b58c3c592a72890b70ef782188266a5dc73eaf95a7b81284e2a264184efc
BLAKE2b-256 checksum
How to use checksums
08b44785be3c2abf9854c1b5e0e3edf473b9e8f4487b8177d5fb9a5f04166c3e
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

Release history Release notifications | RSS feed

0.7.0

2 release files

This release

0.6.0 This release

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page