Metadata-Version: 2.4
Name: tangentopay
Version: 0.7.0
Summary: Official Python SDK for the TangentoPay API
Project-URL: Homepage, https://tangentopay.com
Project-URL: Documentation, https://docs.tangentopay.com
Project-URL: Repository, https://github.com/Grut-Design-Agency/tangentopay-python
Project-URL: Bug Tracker, https://github.com/Grut-Design-Agency/tangentopay-python/issues
Author-email: TangentoPay <dev@tangentopay.com>
License: MIT License
        
        Copyright (c) 2026 TangentoPay
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: africa,fintech,payments,stripe,tangentopay
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: httpx<1.0.0,>=0.27.0
Provides-Extra: async
Requires-Dist: httpx[http2]>=0.27.0; extra == 'async'
Provides-Extra: dev
Requires-Dist: mypy>=1.10.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

# tangentopay-python

Official Python SDK for the [TangentoPay](https://tangentopay.com) API — accept payments, issue refunds, manage wallets, and verify webhooks with a clean, type-safe interface.

[![PyPI version](https://badge.fury.io/py/tangentopay.svg)](https://pypi.org/project/tangentopay/)
[![CI](https://github.com/Grut-Design-Agency/tangentopay-python/actions/workflows/ci.yml/badge.svg)](https://github.com/Grut-Design-Agency/tangentopay-python/actions/workflows/ci.yml)
[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

---

## Table of contents

- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Authentication](#authentication)
- [Token expiry and refresh](#token-expiry-and-refresh)
- [Resources](#resources)
- [Service wallet operations (B2B2C)](#service-wallet-operations-b2b2c)
- [Payouts](#payouts)
- [Merchant wallet top-up](#merchant-wallet-top-up)
- [Payment methods](#payment-methods)
- [Async support](#async-support)
- [Error handling](#error-handling)
- [Webhook verification](#webhook-verification)
- [Supported currencies](#supported-currencies)
- [Security](#security)
- [License](#license)

---

## Requirements

- Python 3.9 or higher
- [`httpx`](https://www.python-httpx.org/) — installed automatically as a dependency

---

## Installation

```bash
pip install tangentopay
```

---

## Quick start

### 1. Accept a customer payment (storefront)

Use `ServiceClient` with your **public service key** (`pk_live_...`).
Get it from: **TangentoPay Dashboard → Services → your service → API Keys**.

```python
import tangentopay

client = tangentopay.ServiceClient("pk_live_your_service_key")

# Product-based checkout (e-commerce / WooCommerce)
session = client.checkout.create(
    products=[
        {"name": "Pro Plan", "price": 49.99, "quantity": 1},
    ],
    currency_code="USD",
    customer_email="buyer@example.com",
    return_url="https://myshop.com/thank-you",
    cancel_url="https://myshop.com/cart",
)

# Redirect your customer to the hosted checkout page
redirect(session.redirect_url)
```

#### Amount-only checkout (payfac / money transfer)

Use when you have **no product catalogue** — payfac integrations or any scenario where you need to collect a fixed amount without listing items via **Stripe checkout**. For collecting money directly into a service wallet via MoMo USSD, use [`service.topups.create()`](#service-wallet-operations-b2b2c) instead.

```python
session = client.checkout.create(
    amount=5000,                   # total amount — no products list needed
    description="Account top-up", # shown on Stripe checkout page
    currency_code="XAF",
    return_url="https://myapp.com/success",
    cancel_url="https://myapp.com/cancel",
)

redirect(session.redirect_url)
```

### 2. Confirm payment before fulfilling an order

```python
# On your /thank-you page the URL contains ?session_id=...
# Poll until the payment is confirmed (up to 60 seconds)

status = client.checkout.wait_for_completion(transaction_uid, timeout=60)
if status.is_completed:
    fulfill_order()
```

### 3. Manage payments on the backend (merchant)

Use `MerchantClient` with your **API token** — keep this server-side only, never expose it in a browser.

```python
import os
import tangentopay

merchant = tangentopay.MerchantClient(api_token=os.environ["TANGENTOPAY_API_TOKEN"])

# List recent payments
page = merchant.payments.list(per_page=20)
for txn in page.data:
    print(txn.transaction_uid, txn.transaction_status, txn.final_amount)

# Issue a refund
refund = merchant.refunds.create(
    transaction_uid="TXN-ABC123",
    amount=49.99,
    reason="Customer request",
    pin="1234",
    recipient_type="stripe",
)

# Check wallet balance
balance = merchant.wallets.main_balance()
print(balance.available_balance, balance.currency_code)
```

### 4. Verify incoming webhooks

Always verify the HMAC signature before trusting any webhook payload.

```python
import os
import tangentopay

WEBHOOK_SECRET = os.environ["TANGENTOPAY_WEBHOOK_SECRET"]

def handle_webhook(raw_body: bytes, signature_header: str):
    try:
        event = tangentopay.Webhook.construct_event(
            payload=raw_body,
            signature=signature_header,
            secret=WEBHOOK_SECRET,
        )
    except tangentopay.WebhookSignatureError:
        return 400  # reject tampered or replayed events

    if event.event == "transaction.payment_completed":
        fulfill_order(event.payload["transaction_uid"])

    return 200
```

---

## Authentication

TangentoPay uses two separate credentials depending on what you are doing:

| Client | Credential | Header sent | When to use |
|---|---|---|---|
| `ServiceClient` | Service key (`pk_live_...`) | `X-Service-Key` | Checkout sessions, service wallet top-ups & withdrawals — backend server |
| `MerchantClient` | API token (Bearer) | `Authorization: Bearer` | Everything sensitive — payments, refunds, payouts, wallets, analytics — backend only, never mobile |

### Understanding the three API key credentials

When you create a key pair (Dashboard → API Keys → Create key), you receive **three separate values**:

| Credential | Format | What it's for |
|---|---|---|
| `public_key` | `pk_live_...` / `pk_test_...` | Pass as `service_key` to `ServiceClient`. Safe for backend servers. |
| `secret_key` | `sk_live_...` / `sk_test_...` | **Never put this in a mobile app or browser.** Server-side only. Reserved for future privileged API routes — no SDK method currently requires it. Store it in a secrets manager. |
| `webhook_secret` | `whs_live_...` / `whs_test_...` | Paste into your webhook handler to verify HMAC-SHA256 signatures. Never expose in client code. |

### Getting your credentials

1. Log in to the [TangentoPay Dashboard](https://tangentopay.com)
2. Go to **Services** and open your service
3. Click **API Keys → Create key** — store all three values immediately, they are shown **once only**
4. Store them as environment variables — never commit them to git

```bash
# .env (never commit this file)
TANGENTOPAY_SERVICE_KEY=pk_live_xxxxxxxxxxxxxxxxxxxxxxxx   # → ServiceClient
TANGENTOPAY_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx    # → reserved, store safely
TANGENTOPAY_WEBHOOK_SECRET=whs_live_xxxxxxxxxxxxxxxxxxxxxxxx  # → webhook verification
TANGENTOPAY_API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6...     # → MerchantClient (Bearer token)
```

### Obtaining a token programmatically

```python
# Step 1 — submit credentials (triggers OTP to your registered device)
token = tangentopay.login(
    email="me@example.com",
    password="secret",
    otp="123456",
)

merchant = tangentopay.MerchantClient(api_token=token)
```

> ⚠️ **The Bearer token expires.** When it does, API calls raise `AuthenticationError` (HTTP 401). See [Token expiry and refresh](#token-expiry-and-refresh) below.

---

## Token expiry and refresh

The Bearer token obtained from `auth.verify_otp()` **expires**. When it does, the next call raises `AuthenticationError`.

Use `set_token()` to swap in a fresh token without rebuilding the client:

```python
import tangentopay

merchant = tangentopay.MerchantClient(api_token=os.environ["TANGENTOPAY_API_TOKEN"])

def call_with_refresh(fn):
    try:
        return fn()
    except tangentopay.AuthenticationError:
        # Re-authenticate and update the token in-place
        new_token = tangentopay.login(
            email=os.environ["TANGENTOPAY_EMAIL"],
            password=os.environ["TANGENTOPAY_PASSWORD"],
            otp=get_otp(),   # retrieve from your TOTP app or email
        )
        merchant.set_token(new_token)  # all resources use the new token automatically
        return fn()                    # retry

# Usage — transparent to callers
page = call_with_refresh(lambda: merchant.payments.list(per_page=20))
```

> **Tip:** Store the token in your database or cache (Redis), and check its expiry before each request rather than waiting for a 401.

---

## Resources

### `ServiceClient` resources

| Resource | Methods | Description |
|---|---|---|
| `checkout` | `create()`, `get_status()`, `wait_for_completion()` | Hosted Stripe checkout sessions |
| `topups` | `create()` | Collect money from a customer's MoMo into the **service wallet** via Fapshi USSD push |
| `withdrawals` | `create()` | Debit the **service wallet** and pay a customer's MoMo account via Fapshi disbursement |

### `MerchantClient` resources

| Resource | Methods | Description |
|---|---|---|
| `auth` | `login()`, `verify_otp()`, `me()`, `logout()`, `change_password()` | Authentication and profile |
| `payments` | `list()`, `get()`, `create_manual()` | View and record payments |
| `refunds` | `create()`, `list()` | Issue and list refunds |
| `topups` | `create()`, `list()` | Add funds to a wallet |
| `payouts` | `create()`, `bulk()`, `list()` | Send funds to recipients |
| `transfers` | `to_main()`, `list()` | Move funds between wallets |
| `wallets` | `main_balance()`, `service_balance()`, `manual_balance()` | Check balances |
| `services` | `list()`, `get()`, `create()`, `update()`, `delete()`, `create_api_key()`, `rotate_api_key()`, `list_api_keys()`, `revoke_api_key()`, `update_webhook()`, `list_payment_methods()`, `set_payment_method()`, `set_payment_methods()` | Manage services, keys, and payment methods |
| `customers` | `list()`, `get()`, `create()`, `update()`, `delete()`, `import_csv()` | Customer management |
| `analytics` | `dashboard()`, `payments_chart()`, `gross_volume()`, `total_payouts()` | Reporting and analytics |

---

## Service setup

### WordPress / WooCommerce plugin

1. Log in to [TangentoPay Dashboard](https://tangentopay.com)
2. **Services → Create service** — type: `plugin`
3. **API Keys → Create key** — type: `live` (and `test` for test mode)
4. Copy all three credentials immediately (shown **once only**):
   - `public_key` (`pk_live_…`) → **Live Service Key** in WooCommerce plugin settings
   - `webhook_secret` (`whs_live_…`) → **Live Webhook Secret** in WooCommerce plugin settings
5. Copy the **Webhook URL** shown in WooCommerce → paste it into **Dashboard → Webhooks**
6. The `secret_key` (`sk_live_…`) is not needed for the WordPress plugin — store it safely

### SDK / server-side / mobile integration

**Which service type should I choose?** The `service_type` field is a **dashboard label only** — it has no effect on which API endpoints or SDK methods are available:

| Type | Use when |
|---|---|
| `mobile` | Native iOS or Android app |
| `sdk` | Integrating via a TangentoPay SDK (Python, Node, PHP) |
| `web` | Web frontend or server-rendered website |
| `plugin` | WordPress / WooCommerce plugin |
| `api` | Direct API calls / custom backend |

```python
import tangentopay

merchant = tangentopay.login("me@example.com", "password", "123456")

# Create key pair (run once during setup)
pair = merchant.services.create_api_key(
    service_id,
    key_name="Production server",
    key_type="live",
)
# Store immediately — shown once:
print(pair.public_key)      # pk_live_…  → X-Service-Key (ServiceClient)
print(pair.secret_key)      # sk_live_…  → reserved for future privileged routes — store safely, never expose
print(pair.webhook_secret)  # whs_live_… → webhook verification

# Rotate when needed (old credentials stop working immediately)
rotated = merchant.services.rotate_api_key(service_id, pair.id)
print(rotated.public_key)
print(rotated.webhook_secret)
```

**Environment variables:**
```bash
TANGENTOPAY_SERVICE_KEY=pk_live_…        # X-Service-Key for checkout and service wallet ops
TANGENTOPAY_SECRET_KEY=sk_live_…         # reserved — store securely, not in mobile apps
TANGENTOPAY_WEBHOOK_SECRET=whs_live_…    # from API Keys, not Webhook settings
TANGENTOPAY_TEST_SERVICE_KEY=pk_test_…
TANGENTOPAY_TEST_WEBHOOK_SECRET=whs_test_…
```

---

## Service wallet operations (B2B2C)

If you are building a **B2B2C app** (e.g. a "Jangi" wallet app where end-users hold balances inside your app), use `ServiceClient` to move money between your users' MoMo accounts and your **service wallet**. Your app tracks each user's individual balance in your own database; TangentoPay holds the aggregate service balance.

> **Wallet architecture:**
> - **Main wallet** — your merchant account balance. Receives merchant top-ups via `MerchantClient`. Unrelated to end-user flows.
> - **Service wallet** — per-service balance. Receives customer MoMo collections (`service.topups.create()`) and is debited for customer MoMo payouts (`service.withdrawals.create()`).

### Collect from a customer (service top-up)

```python
import tangentopay

service = tangentopay.ServiceClient(os.environ["TANGENTOPAY_SERVICE_KEY"])

# Customer "Alice" wants to load 5 000 XAF into her Jangi wallet
txn = service.topups.create(
    amount=5000,                        # XAF — net amount credited to service wallet
    phone="677001234",                  # Alice's MTN MoMo — format: 6XXXXXXXX (no country code)
    idempotency_key="jangi-topup-alice-order-001",
)

print(txn["status"])  # "pending"
# ✅ Alice receives a USSD push on her phone.
# ✅ Once approved, Fapshi fires a webhook and TangentoPay credits your service wallet.
# ✅ Listen for transaction.completed webhook, then credit Alice's balance in your DB.
```

**Fapshi fee on collections:** 2.2% added on top — Alice pays ~5 112 XAF; your service wallet receives 5 000 XAF.
**Minimum: 100 XAF. Maximum: 500 000 XAF.**

### Pay out to a customer (service withdrawal)

```python
# Customer "Bob" wants to cash out 10 000 XAF from his Jangi wallet
txn = service.withdrawals.create(
    amount=10000,                       # XAF debited from service wallet
    phone="690001234",                  # Bob's Orange Money number
    idempotency_key="jangi-cashout-bob-req-42",
)

print(txn["status"])      # "processing"
print(txn["net_amount"])  # 9600 — Bob receives 96% after 4% TangentoPay fee
# ✅ Service wallet debited 10 000 XAF immediately.
# ✅ Fapshi sends Bob 9 600 XAF on his MoMo (Fapshi charges 0% on disbursements).
# ✅ On failure, the 10 000 XAF is automatically re-credited to your service wallet.
```

**Minimum: 500 XAF. TangentoPay fee: 4% deducted from proceeds.**

### Checking service wallet balance

```python
import tangentopay

merchant = tangentopay.MerchantClient(api_token=os.environ["TANGENTOPAY_API_TOKEN"])

# Service wallet — funded by customer MoMo collections
service_bal = merchant.wallets.service_balance()
print(service_bal)   # [{'currency': 'XAF', 'available': 50000.0, 'incoming': 0.0}]

# Main wallet — funded by merchant top-ups via MerchantClient
main_bal = merchant.wallets.main_balance()
print(main_bal)
```

> **Why is my service wallet balance zero after a top-up?** Merchant top-ups via `MerchantClient.topups.create()` go to the **main wallet**, not the service wallet. Use `ServiceClient.topups.create()` (customer MoMo collection) to fund the service wallet.

---

## Payouts

Payouts let merchants withdraw funds from their TangentoPay wallet to a bank account, mobile money number, or another TangentoPay account. All payouts use `MerchantClient`.

> **Fee:** TangentoPay charges a **4% fee** deducted from proceeds. `transaction.net_amount` shows what the recipient receives.

### Bank payout (Stripe)

Requires a saved payout method (`ba_...` token linked via the Dashboard or API).
Processing time: 2–7 business days.

```python
txn = merchant.payouts.create(
    amount=50_000,
    currency_code="XAF",
    pin="1234",
    recipient_type="bank",
    recipient_details={
        "bank_account_id": "ba_xxx",   # from saved payout methods
        "bank_name": "Ecobank",
        "account_number": "123456789",
        "first_name": "Jane",
        "last_name": "Doe",
    },
)
print(txn.net_amount)   # amount after 4% fee
```

### Mobile Money payout (MTN MoMo / Orange Money)

XAF only. Near-instant via Fapshi USSD push.

```python
txn = merchant.payouts.create(
    amount=10_000,
    currency_code="XAF",
    pin="1234",
    recipient_type="mtn_momo",   # or "orange_money"
    recipient_details={
        "mobile_money_number": "677000000",  # 6XXXXXXXX — no country code
    },
)
```

### TangentoPay wallet transfer

Instant internal transfer to another TangentoPay account — works across all currencies.

```python
txn = merchant.payouts.create(
    amount=5_000,
    currency_code="XAF",
    pin="1234",
    recipient_type="tangentopay_wallet",
    recipient_details={
        "wallet_tag": "JANE1234",    # 4–32 uppercase alphanumeric chars
        "account_name": "Jane Doe",  # display name from tag lookup
    },
)
print(txn.transaction_uid, txn.net_amount)
```

> **Looking up a wallet tag:** Use the tag-lookup endpoint before initiating a transfer to pre-fill `account_name` and confirm the recipient exists.

### Virtual card withdrawal (Stripe Instant Payout)

Withdraw to a Visa or Mastercard **debit card** saved via `POST /payout-methods`. Funds arrive in **minutes** via Stripe Instant Payout. **USD only.**

> **Prerequisites:** The card must be saved first using the Stripe.js token flow (`method_type: "card"`, `stripe_token_id: "tok_..."`). See the API docs for the full card onboarding flow.

```python
# Recommended — reference the saved card payout method by ID
txn = merchant.payouts.create(
    amount=100.00,
    currency_code="USD",
    pin="1234",
    recipient_type="virtual_card",
    recipient_details={
        "payout_method_id": 3,   # ID from GET /payout-methods (method_type: "card")
    },
)
print(txn.transaction_uid, txn.net_amount)

# Alternative — pass the Stripe card external account ID directly
txn = merchant.payouts.create(
    amount=100.00,
    currency_code="USD",
    pin="1234",
    recipient_type="virtual_card",
    recipient_details={
        "stripe_card_id": "card_1ObbCP2eZvKYlo2C5OlqLpOw",   # card_... from POST /payout-methods
    },
)
```

> **Note:** TangentoPay charges a 4% fee. Stripe also charges a ~1% instant payout fee on top. The `net_amount` on the returned transaction reflects both deductions.

### Bulk payouts (payroll / commission)

```python
results = merchant.payouts.bulk([
    {
        "amount": 150_000, "currency_code": "XAF", "pin": "1234",
        "recipient_type": "mtn_momo",
        "recipient_details": {"mobile_money_number": "677111111"},
    },
    {
        "amount": 200_000, "currency_code": "XAF", "pin": "1234",
        "recipient_type": "tangentopay_wallet",
        "recipient_details": {"wallet_tag": "BOB9999", "account_name": "Bob Smith"},
    },
])
```

### Async variant

```python
txn = await async_merchant.payouts.create(
    amount=5_000,
    currency_code="XAF",
    pin="1234",
    recipient_type="tangentopay_wallet",
    recipient_details={"wallet_tag": "JANE1234", "account_name": "Jane Doe"},
)
```

---

## Merchant wallet top-up

Top-up lets the **merchant** (you, the business owner) add funds to your TangentoPay **main wallet** via card (Stripe) or Mobile Money (Fapshi). It uses the `MerchantClient`.

> ⚠️ This is for **merchant self-funding** only. For collecting money from your app's end-users into a service wallet, see [Service wallet operations](#service-wallet-operations-b2b2c) above.

### Card top-up (Stripe Checkout)

### Why `idempotency_key` is required

Every call to `topups.create()` is an independent Python function call. If you retry after a network failure without passing the same key, the server sees a completely new request and creates a second Stripe Checkout Session — potentially charging the user twice.

The rule is simple: **generate the key once, store it, reuse it on every retry of the same top-up intent**.

```python
import tangentopay

merchant = tangentopay.MerchantClient(api_token=os.environ["TANGENTOPAY_API_TOKEN"])

# Step 1 — generate ONCE and store in your session / database
key = tangentopay.generate_idempotency_key()

# Step 2 — initiate the top-up (safe to retry with the same key)
session = merchant.topups.create(
    amount=50.00,
    currency_code="USD",
    idempotency_key=key,          # required
    return_url="https://app.com/topup/success",
    cancel_url="https://app.com/topup/cancel",
)

# Step 3 — redirect the user to complete payment
redirect(session.redirect_url)
```

**On retry (network timeout, double-tap):**

```python
# Same key → server returns the existing session, no new charge
session = merchant.topups.create(
    amount=50.00,
    currency_code="USD",
    idempotency_key=key,   # same key as before
    return_url="https://app.com/topup/success",
)
# session.redirect_url is the same Stripe URL — user continues where they left off
```

**Async variant:**

```python
session = await async_merchant.topups.create(
    amount=50.00,
    currency_code="USD",
    idempotency_key=key,
    return_url="https://app.com/topup/success",
)
```

### Mobile Money top-up (MTN MoMo / Orange Money — XAF only)

Fapshi sends a USSD push to the subscriber's handset. The amount is debited immediately and credited to your wallet after confirmation.

> **Fee note:** Fapshi adds a 2.2% processing fee on top of your requested amount. The `gross_amount` on the returned transaction shows the total charged to the subscriber's phone.

```python
key = tangentopay.generate_idempotency_key()

session = merchant.topups.create(
    amount=10_000,            # XAF — amount you want credited to your wallet
    currency_code="XAF",
    idempotency_key=key,
    payment_source="mtn_momo",
    phone="677000000",        # Cameroon format: 6XXXXXXXX — no +237 prefix
)
# A USSD push is sent to 677000000 — poll status to confirm
```

For Orange Money, use `payment_source="orange_money"` with the same `phone` format.

**Check the transaction outcome:**

```python
import time

for _ in range(12):          # poll for up to ~60 s
    status = merchant.topups.list(per_page=1).data[0]
    if status.is_completed:
        print(f"Credited {status.final_amount} {status.currency_code}")
        break
    if status.is_failed:
        print("Top-up failed or was cancelled by subscriber")
        break
    time.sleep(5)
```

### Top-up without products

Unlike checkout sessions, top-ups do not require a products array. Pass `amount` + `currency_code` directly — the payment line item is created automatically.

---

## Payment methods

Each service has its own set of enabled payment methods. Cards (Visa/Mastercard) are always enabled. Company accounts that have completed KYB verification can enable additional Stripe methods (Google Pay, Apple Pay, Alipay, WeChat Pay) per service via the SDK.

| Method | Default | Availability |
|---|---|---|
| Visa / Mastercard / Amex (card) | ✅ Always enabled | All account types |
| Google Pay | Off | Company accounts with KYB verification |
| Apple Pay | Off | Company accounts with KYB verification |
| Alipay | Off | Company accounts with KYB verification |
| WeChat Pay | Off | Company accounts with KYB verification |
| MTN MoMo (`mtn_momo`) | ✅ Available via Fapshi | XAF (Cameroon) — wallet top-ups |
| Orange Money (`orange_money`) | ✅ Available via Fapshi | XAF (Cameroon) — wallet top-ups |

### Managing payment methods per service

```python
# List all payment methods for a service (with enabled/locked/reason status)
methods = merchant.services.list_payment_methods(service_id)
# [ServicePaymentMethod(slug='card', enabled=True, locked=False), ...]

# Toggle a single method on or off
merchant.services.set_payment_method(service_id, 'google_pay', enabled=True)

# Replace the entire set of enabled methods at once
# card must always be included
merchant.services.set_payment_methods(service_id, ['card', 'apple_pay', 'alipay'])
```

Checkout sessions for that service will only show the methods you have enabled. If the account is not KYB-verified, non-card methods are returned as `locked=True` with a human-readable `reason`.

> **MoMo top-ups** use `payment_source="mtn_momo"` or `"orange_money"` with a `phone="6XXXXXXXX"` parameter. See [Wallet top-up](#wallet-top-up) for the full example.

---

## Service request logs

Every API request authenticated via `X-Service-Key` is automatically logged. The logs mirror the **Dashboard → Service → Logs** view.

```python
# List recent logs for a service (last 25 by default)
page = merchant.logs.list(
    service_id,
    per_page=25,
    status=500,        # filter by HTTP status code
    method="POST",     # filter by HTTP method
    date_from="2026-05-01",
    date_to="2026-05-31",
)

for entry in page["data"]["data"]:
    print(entry["request_id"], entry["status_code"], entry["application"], f"{entry['duration_ms']}ms")
# a1b2c3d4-...  200  WooCommerce Inc.  342ms

# Fetch a single entry by its request ID
# (same UUID as the X-Request-ID response header)
detail = merchant.logs.get(service_id, "a1b2c3d4-e5f6-7890-abcd-ef1234567890")
print(detail["data"]["transaction_uid"])  # TXN-...
```

Async version:

```python
page = await merchant.logs.list(service_id, per_page=25)
```

---

## Async support

Every client has an async counterpart — `AsyncServiceClient` and `AsyncMerchantClient` — with identical methods that return awaitables. Use these with FastAPI, Starlette, or any `asyncio`-based framework.

```python
import asyncio
import tangentopay

async def main():
    client = tangentopay.AsyncServiceClient("pk_live_your_service_key")

    session = await client.checkout.create(
        products=[{"name": "Pro Plan", "price": 49.99, "quantity": 1}],
        currency_code="USD",
        return_url="https://myshop.com/thank-you",
        cancel_url="https://myshop.com/cart",
    )
    print(session.redirect_url)

asyncio.run(main())
```

```python
# FastAPI example
from fastapi import FastAPI, Request
import tangentopay

app = FastAPI()
merchant = tangentopay.AsyncMerchantClient(api_token=os.environ["TANGENTOPAY_API_TOKEN"])

@app.get("/payments")
async def list_payments():
    page = await merchant.payments.list(per_page=20)
    return {"total": page.total, "data": [p.transaction_uid for p in page.data]}
```

---

## Error handling

All SDK errors inherit from `tangentopay.TangentoPayError` so you can catch everything with one clause or be specific.

```python
try:
    refund = merchant.refunds.create(
        transaction_uid="TXN-001",
        amount=9999.00,
        reason="test",
        pin="wrong",
        recipient_type="stripe",
    )
except tangentopay.ValidationError as e:
    # Server-side field validation failed
    print(e.errors)           # {"amount": ["exceeds original transaction amount"]}
except tangentopay.AuthenticationError:
    # Token is invalid or expired — re-authenticate
    print("Invalid or expired token")
except tangentopay.PermissionError:
    # Authenticated but not allowed to perform this action
    print("Insufficient permissions")
except tangentopay.NotFoundError:
    print("Transaction not found")
except tangentopay.RateLimitError as e:
    # SDK already retried with exponential backoff and gave up
    print(f"Rate limited — retry after {e.retry_after}s")
except tangentopay.ServerError:
    # 5xx — SDK retried 3 times automatically before raising
    print("TangentoPay server error")
except tangentopay.NetworkError:
    # Timeout, DNS failure, connection refused
    print("Network error — check your connection")
except tangentopay.TangentoPayError as e:
    # Catch-all for any other SDK error
    print(f"Error {e.http_status}: {e.message}")
```

### Exception reference

| Exception | HTTP status | Notes |
|---|---|---|
| `AuthenticationError` | 401 | Invalid or expired API key / token |
| `PermissionError` | 403 | Authenticated but not authorised |
| `NotFoundError` | 404 | Resource does not exist |
| `ValidationError` | 422 | Field-level errors in `e.errors` dict |
| `RateLimitError` | 429 | After all retries exhausted; `e.retry_after` seconds |
| `ServerError` | 5xx | After 3 automatic retries |
| `NetworkError` | — | Timeout, DNS, connection error |
| `WebhookSignatureError` | — | Invalid HMAC, tampered payload, or replay attack |

---

## Webhook verification

TangentoPay signs every webhook with HMAC-SHA256 and includes a timestamp to prevent replay attacks. The SDK verifies both automatically.

```python
from tangentopay.webhook import Webhook
import tangentopay

event = Webhook.construct_event(
    payload=raw_body,           # bytes or str — the raw request body
    signature=sig_header,       # value of the X-TangentoPay-Signature header
    secret=webhook_secret,      # whs_live_... or whs_test_... from API Keys (shown once)
    timestamp_tolerance_seconds=300,  # default — reject events older than 5 minutes
)
```

**Signature header format:**

```
X-TangentoPay-Signature: t=1716134400,sha256=abcdef1234...
```

**Django example:**

```python
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from tangentopay.webhook import Webhook
import tangentopay

@csrf_exempt
def webhook(request):
    try:
        event = Webhook.construct_event(
            payload=request.body,
            signature=request.headers.get("X-TangentoPay-Signature", ""),
            secret=settings.TANGENTOPAY_WEBHOOK_SECRET,
        )
    except tangentopay.WebhookSignatureError as e:
        return HttpResponse(str(e), status=400)

    match event.event:
        case "transaction.payment_completed":
            handle_payment(event.payload["transaction_uid"])
        case "transaction.refund_completed":
            handle_refund(event.payload["transaction_uid"])

    return HttpResponse(status=200)
```

**Flask example:**

```python
from flask import Flask, request, abort
from tangentopay.webhook import Webhook
import tangentopay

app = Flask(__name__)

@app.post("/webhooks/tangentopay")
def webhook():
    try:
        event = Webhook.construct_event(
            payload=request.data,
            signature=request.headers.get("X-TangentoPay-Signature", ""),
            secret=app.config["TANGENTOPAY_WEBHOOK_SECRET"],
        )
    except tangentopay.WebhookSignatureError:
        abort(400)

    if event.event == "transaction.payment_completed":
        handle_payment(event.payload["transaction_uid"])

    return "", 200
```

### Supported webhook events

| Event | When it fires |
|---|---|
| `transaction.payment_completed` | Customer payment (checkout) successfully processed |
| `transaction.payment_failed` | Customer payment attempt failed |
| `transaction.refund_completed` | Refund issued successfully |
| `transaction.payout_completed` | Merchant payout sent to recipient |
| `transaction.topup_completed` | Merchant main-wallet top-up completed |
| `transaction.completed` | Service wallet top-up or withdrawal confirmed by Fapshi |

---

## Supported currencies

TangentoPay supports Stripe's full currency list. Commonly used currencies:

| Code | Currency |
|---|---|
| `USD` | US Dollar |
| `EUR` | Euro |
| `GBP` | British Pound |
| `XAF` | Central African CFA Franc (Cameroon, Chad, Congo, Gabon…) |
| `NGN` | Nigerian Naira |
| `GHS` | Ghanaian Cedi |
| `KES` | Kenyan Shilling |
| `ZAR` | South African Rand |

> **XAF note:** Amounts in XAF are zero-decimal — pass `500` not `5.00`. The SDK handles this automatically when you provide `currency_code="XAF"`.

---

## Contributing

See [CONTRIBUTING.md](https://github.com/Grut-Design-Agency/tangentopay-python/blob/main/CONTRIBUTING.md) for development setup, branch naming, commit conventions, code style, and release instructions.

---

## Security

Security issues should **not** be reported via public GitHub issues.

Please report vulnerabilities by emailing **security@tangentopay.com**. We will acknowledge within 48 hours and aim to release a fix within 7 days for critical issues.

See [SECURITY.md](SECURITY.md) for the full security policy.

### Security features built into this SDK

- **HTTPS enforced** — the SDK rejects any `base_url` that does not use `https://`, preventing accidental credential leakage over plain HTTP
- **Header injection protection** — credentials are validated for CR/LF characters at construction time, preventing HTTP header injection attacks
- **Webhook replay protection** — `construct_event()` rejects events with timestamps outside a configurable tolerance window (default 5 minutes)
- **Webhook hex validation** — the SHA-256 digest in the signature header is validated as exactly 64 hex characters before comparison
- **Timing-safe comparison** — webhook signatures are verified with `hmac.compare_digest()` to prevent timing side-channel attacks
- **Payload size limit** — webhook payloads over 10 MB are rejected before any HMAC computation
- **Credential masking** — API keys and tokens are masked in `repr()` output so they do not appear in logs or debug output
- **Capped retry backoff** — the `Retry-After` value from the server is capped at 60 seconds to prevent server-controlled denial-of-service
- **Protected auth headers** — `extra_headers` cannot override `Authorization` or `X-Service-Key`

---

## License

MIT — see [LICENSE](LICENSE) for the full text.

---

<p align="center">
  Built with ❤️ by the <a href="https://tangentopay.com">TangentoPay</a> team
</p>
