Metadata-Version: 2.4
Name: blaaiz-python-sdk
Version: 1.7.0
Summary: A comprehensive Python SDK for the Blaaiz RaaS (Remittance as a Service) API
Home-page: https://github.com/blaaiz/blaaiz-python-sdk
Author: Blaaiz Team
Author-email: Blaaiz Team <onboarding@blaaiz.com>
License: MIT
Project-URL: Homepage, https://github.com/blaaiz/blaaiz-python-sdk
Project-URL: Documentation, https://docs.business.blaaiz.com
Project-URL: Repository, https://github.com/blaaiz/blaaiz-python-sdk
Project-URL: Bug Tracker, https://github.com/blaaiz/blaaiz-python-sdk/issues
Keywords: blaaiz,remittance,payment,fintech,api,sdk
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
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 :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=6.0; extra == "dev"
Requires-Dist: pytest-cov>=2.0; extra == "dev"
Requires-Dist: black>=21.0; extra == "dev"
Requires-Dist: flake8>=3.8; extra == "dev"
Requires-Dist: mypy>=0.800; extra == "dev"
Requires-Dist: twine>=3.0; extra == "dev"
Requires-Dist: wheel>=0.36; extra == "dev"
Dynamic: author
Dynamic: home-page
Dynamic: license-file
Dynamic: requires-python

# Blaaiz Python SDK

A comprehensive Python SDK for the Blaaiz RaaS (Remittance as a Service) API. This SDK provides easy-to-use methods for payment processing, collections, payouts, customer management, and more.

## Installation

```bash
pip install blaaiz-python-sdk
```

## Quick Start

```python
from blaaiz import Blaaiz

# Initialize the SDK
blaaiz = Blaaiz('your-api-key-here', base_url='https://api-dev.blaaiz.com')

# Test the connection
is_connected = blaaiz.test_connection()
print(f'API Connected: {is_connected}')
```

## Authentication

The SDK supports two authentication methods: **OAuth 2.0 client credentials** (recommended) and a **legacy API key**.

### OAuth 2.0 (client credentials)

Provide a `client_id` and `client_secret`. The SDK fetches a bearer token from `POST {base_url}/oauth/token`, caches it in memory, and refreshes it automatically 60 seconds before it expires.

```python
import os
from blaaiz import Blaaiz

blaaiz = Blaaiz(
    client_id=os.environ['BLAAIZ_CLIENT_ID'],
    client_secret=os.environ['BLAAIZ_CLIENT_SECRET'],
)
```

By default the SDK requests the full set of scopes. Pass `oauth_scope` (a space-separated string) to request a narrower set:

```python
blaaiz = Blaaiz(
    client_id=os.environ['BLAAIZ_CLIENT_ID'],
    client_secret=os.environ['BLAAIZ_CLIENT_SECRET'],
    oauth_scope='wallet:read transaction:read',
)
```

The default set does not include `signa-id:release`. To call the `blaaiz.signa_id` release methods, send `oauth_scope` with that scope. See [Signa ID Release](#signa-id-release).

### Legacy API key

```python
import os
from blaaiz import Blaaiz

blaaiz = Blaaiz(os.environ['BLAAIZ_API_KEY'])
```

When both OAuth credentials and an API key are configured, OAuth is used. If neither a `client_id`/`client_secret` pair nor an `api_key` is provided, a `BlaaizError` is raised.

## Features

- **Customer Management**: Create, update, and manage customers with KYC verification
- **Collections**: Support for multiple collection methods (Open Banking, Card, Crypto, Bank Transfer)
- **Payouts**: Bank transfers, mobile money and Interac payouts across multiple currencies
- **Virtual Bank Accounts**: Create and manage virtual accounts for NGN collections
- **Wallets**: Multi-currency wallet management
- **Transactions**: Transaction history and status tracking
- **Signa**: Merchant KYC/KYB verification sessions and document collection
- **Webhooks**: Webhook configuration and management with signature verification
- **Files**: Document upload with pre-signed URLs
- **Fees**: Real-time fee calculations and breakdowns
- **Rates**: Exchange rate lookups
- **Swaps**: Swap funds between business wallets
- **Refunds**: Refund transactions and track refund status
- **Banks & Currencies**: Access to supported banks, mobile money operators, payee/IBAN verification and currencies
- **Merchant Reference**: Attach your own reference to payouts and collections for reconciliation

## Supported Currencies & Methods

### Collections
- **CAD**: Interac (push mechanism)
- **NGN**: Bank Transfer (VBA) and Card Payment
- **USD**: Card Payment
- **EUR/GBP**: Open Banking

### Payouts
- **Bank Transfer**: All supported currencies
- **Mobile Money**: KES, UGX, TZS, XOF, GHS
- **Interac**: CAD transactions

## API Reference

### Customer Management

#### Create a Customer

```python
# Individual customer
customer = blaaiz.customers.create({
    'first_name': "John",  # Required for individual
    'last_name': "Doe",    # Required for individual
    'type': "individual",
    'email': "john.doe@example.com",
    'country': "NG",
    'id_type': "passport",  # drivers_license, passport, id_card, resident_permit
    'id_number': "A12345678",
})

# Business customer
business_customer = blaaiz.customers.create({
    'type': "business",
    'business_name': "Company Name",           # Required for business
    'email': "business@example.com",
    'country': "NG",
    'registration_number': "RC123456",         # Required for business
    'incorporation_country': "NG",             # Required for business
})
# Note: id_type / id_number are for individual customers only and must not be
# sent for a business customer.

print(f'Customer ID: {customer["data"]["data"]["id"]}')
```

#### Get Customer

```python
customer = blaaiz.customers.get('customer-id')
print(f'Customer: {customer["data"]}')
```

#### List All Customers

```python
customers = blaaiz.customers.list()
print(f'Customers: {customers["data"]}')
```

You can also pass optional filters and opt-in pagination. Supported filters
are `email`, `id_number`, `registration_number`, `verification_status`, and
`type`. Set `paginate=True` to receive a paginated response that includes
`links` and `meta` (with `current_page`, `total`, etc.) alongside `data`.

```python
verified = blaaiz.customers.list({
    "email": "john@example.com",
    "verification_status": "VERIFIED",
    "type": "individual",
    "paginate": True,
})
print(f'Page: {verified["data"]["meta"]["current_page"]}')
print(f'Customers: {verified["data"]["data"]}')
```

#### Update Customer

```python
updated_customer = blaaiz.customers.update('customer-id', {
    'first_name': "Jane",
    'email': "jane.doe@example.com"
})
```

#### List Customer Beneficiaries

```python
beneficiaries = blaaiz.customers.list_beneficiaries('customer-id')
print(f'Beneficiaries: {beneficiaries["data"]}')
```

#### Get Specific Beneficiary

```python
beneficiary = blaaiz.customers.get_beneficiary('customer-id', 'beneficiary-id')
print(f'Beneficiary: {beneficiary["data"]}')

### File Management & KYC

#### Upload Customer Documents

**Method 1: Complete File Upload (Recommended)**
```python
# Option A: Upload from bytes
with open('passport.jpg', 'rb') as f:
    file_data = f.read()

result = blaaiz.customers.upload_file_complete('customer-id', {
    'file': file_data,
    'file_category': 'identity',  # identity, identity_back, proof_of_address, liveness_check
    'filename': 'passport.jpg',
    'content_type': 'image/jpeg'
})

# Option B: Upload from Base64 string
result = blaaiz.customers.upload_file_complete('customer-id', {
    'file': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
    'file_category': 'identity'
})

# Option C: Upload from Data URL
result = blaaiz.customers.upload_file_complete('customer-id', {
    'file': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
    'file_category': 'identity'
})

# Option D: Upload from Public URL
result = blaaiz.customers.upload_file_complete('customer-id', {
    'file': 'https://example.com/documents/passport.jpg',
    'file_category': 'identity'
})

print(f'Upload complete: {result["data"]}')
print(f'File ID: {result["file_id"]}')
```

**Method 2: Manual 3-Step Process**
```python
# Step 1: Get pre-signed URL
presigned_url = blaaiz.files.get_presigned_url({
    'customer_id': 'customer-id',
    'file_category': 'identity'
})

# Step 2: Upload file to the pre-signed URL (implement your file upload logic)
# Step 3: Associate file with customer
file_association = blaaiz.customers.upload_files('customer-id', {
    'id_file': presigned_url['data']['data']['file_id']
})
```

### Verify a Customer with a Signa Session

If the person already passed a Signa session of your business, link that session to an individual customer. The person does not send their documents again.

```python
result = blaaiz.customers.link_kyc_session('customer-id', 'signa-session-id')
print(result['data']['data']['verification_status'])  # VERIFIED
```

The call needs the `customer:write` and `compliance-kyc:pii:read` scopes. The session must be `APPROVED`, include `DOCUMENTS`, and be approved in the last 365 days. The customer details must agree with the verified person. If a condition fails, the API returns HTTP 400, 409, or 422, and the message names the condition.

On success, the customer becomes `VERIFIED` and a `customer.status_changed` webhook fires. Blaaiz then copies the verified name, date of birth, document details, and images to the customer.

### Business Verification (KYB)

#### Submit a Customer for Verification

```python
blaaiz.customers.submit('customer-id')
```

#### Upgrade KYB Scope

```python
# Upgrade a MINIMAL business customer to FULL. Ownership must sum to exactly 100.
blaaiz.customers.upgrade_kyb_scope('customer-id', {
    'owners': [
        {'first_name': "Jane", 'last_name': "Doe", 'ownership_percentage': 100}
    ]
})
```

#### Owner Identity Files

```python
# Step 1: Get a presigned URL for an owner document
presigned = blaaiz.customers.get_owner_file_presigned_url('customer-id', 'owner-id', {
    'file_category': "id_document_front"  # or "id_document_back"
})

# Step 2: Upload the file to the presigned URL, then associate it
blaaiz.customers.upload_owner_files('customer-id', 'owner-id', {
    'id_document_front': presigned['file_id'],
    'id_document_back': "another-file-id"  # Optional
})
```

#### Remove an Owner

```python
blaaiz.customers.delete_owner('customer-id', 'owner-id')
```

#### Customer Documents

```python
# Get a presigned URL for a document
presigned = blaaiz.customers.get_document_presigned_url('customer-id')

# Create a document record
document = blaaiz.customers.create_document('customer-id', {
    'type': "PROOF_OF_ADDRESS",       # See API reference for the full enum
    'name': "Utility bill",
    'file_id': presigned['file_id'],
    'description': "March 2026"        # Optional
})

# List, get, update and delete
documents = blaaiz.customers.list_documents('customer-id')
one = blaaiz.customers.get_document('customer-id', document['data']['id'])
blaaiz.customers.update_document('customer-id', document['data']['id'], {'name': "Renamed"})
blaaiz.customers.delete_document('customer-id', document['data']['id'])
```

### Signa Merchant KYC/KYB Sessions

Signa lets a business create and manage KYC/KYB verification sessions for its own customers. Each session can require `DOCUMENTS`, `SELFIE`, `FACE_MATCH`, or `PROOF_OF_ADDRESS`.

A session has one of two fulfilment modes:

- `HEADLESS`: your server uploads the documents and submits the session. Document uploads and `submit_session` work only in this mode.
- `HOSTED`: the customer opens a Blaaiz verification link. `issue_verification_link` works only in this mode.

The API currently accepts only these combinations of `requirements` and `fulfilment_mode`. It refuses any other combination when you create the session.

| `requirements` | `fulfilment_mode` |
| --- | --- |
| `DOCUMENTS` | `HEADLESS` |
| `DOCUMENTS`, `SELFIE`, `FACE_MATCH` | `HOSTED` |
| `DOCUMENTS`, `SELFIE`, `FACE_MATCH`, `PROOF_OF_ADDRESS` | `HOSTED` |
| `DOCUMENTS`, `PROOF_OF_ADDRESS` | `HOSTED` or `HEADLESS` |
| `PROOF_OF_ADDRESS` | `HOSTED` or `HEADLESS` |

If you do not send `fulfilment_mode`, the API uses the first mode in the row for your set. The settings of your business can limit the modes that you can use.

```python
session = blaaiz.signa.create_session({
    'customer_reference': "customer-123",
    'idempotency_key': "signa-request-123",
    'requirements': ["DOCUMENTS"],
    'fulfilment_mode': "HEADLESS",
    'applicant': {
        'first_name': "Ada",
        'last_name': "Lovelace",
        'country': "GBR"
    }
})

session_id = session['data']['data']['id']
print(f'Session: {session["data"]["data"]}')

# limit is 1 to 100 (default 20); offset is 0 or more (default 0)
sessions = blaaiz.signa.list_sessions({'limit': 20, 'offset': 0})
current = blaaiz.signa.get_session(session_id)
```

`create_session` also accepts an optional `redirect_url`, an https URL on your site. When the person finishes on a Blaaiz-hosted verification page, the page sends the person to this URL with `session_id` added. The URL never carries the result.

#### Uploading Signa Documents

For a very small document, send the content inline as base64. The API can reject a request body that is larger than approximately 8 KB. Each request must use one transport: `content_base64` or a staged `file_name`.

```python
blaaiz.signa.upload_session_document(session_id, {
    'filename': "passport.jpg",
    'content_type': "image/jpeg",
    'id_doc_type': "PASSPORT",
    'country': "GBR",
    'content_base64': "...base64 document bytes..."
})
```

For all other documents, use the staged upload flow. The upload URL response contains the exact headers to send with the direct `PUT` request. After the PUT request succeeds, register the returned `file_name` with Signa.

```python
upload = blaaiz.signa.create_document_upload_url(session_id, {
    'file_name': "passport.jpg",
    'id_doc_type': "PASSPORT"
})

# PUT the document bytes to upload['data']['data']['url'] with upload['data']['data']['headers'].
# The URL is short-lived and its headers are part of its signature.

blaaiz.signa.upload_session_document(session_id, {
    'filename': "passport.jpg",
    'content_type': "image/jpeg",
    'id_doc_type': "PASSPORT",
    'country': "GBR",
    'file_name': upload['data']['data']['file_name']
})
```

After you upload the documents, submit the session. To stop a session, cancel it.

```python
blaaiz.signa.submit_session(session_id)
blaaiz.signa.cancel_session(other_session_id)  # a session that you do not need
```

Each Signa method maps to one `/api/external/compliance/kyc/sessions` route. The API returns HTTP 422 for a validation failure and HTTP 404 for an unknown session, both through the SDK's normal `BlaaizError`.

For a `HOSTED` session, issue or rotate the customer's verification link:

```python
link = blaaiz.signa.issue_verification_link(hosted_session_id)  # a HOSTED session
print(f'Verification link: {link["data"]["data"]["verification_link"]}')
```

To open a `HOSTED` session in a popup on your own page with the Signa web SDK, issue an access token from your server. Send `access_token` to your page and call `signa.startSession({ accessToken })`.

```python
token = blaaiz.signa.issue_access_token(hosted_session_id)
access_token = token['data']['data']['access_token']
expires_at = token['data']['data']['expires_at']
```

The token is valid for 30 minutes. While more than 10 minutes remain, a new call returns the same token. With 10 minutes or less, the call returns a new token, and the previous token and verification link stop working. The token is a bearer credential: do not put it in a URL and do not log it.

#### Read Captured Data

Signa can return the personal data that it captured during a session: the applicant's details and the uploaded documents. These three reads need the `compliance-kyc:pii:read` scope. Blaaiz grants this scope to a credential only on request. The SDK requests the scope by default, and the API ignores it for a credential that does not hold it. With OAuth, a token without the scope gets HTTP 403.

Each of the three reads returns HTTP 409 until the session reaches a verdict. A session reaches a verdict when its status becomes `APPROVED` or `REJECTED`.

**Warning:** The response data is personal data. Do not log this data. Do not cache this data. Each response carries the `Cache-Control: no-store` header.

```python
applicant_data = blaaiz.signa.get_session_applicant_data(session_id)
applicant = applicant_data['data']['data']  # None, or the captured applicant fields

documents = blaaiz.signa.list_session_documents(session_id)
available = [d for d in documents['data']['data'] if d['available']]

download = blaaiz.signa.get_session_document(session_id, available[0]['id'])
download_url = download['data']['data']['url']  # Valid for 15 minutes
```

`get_session_document` returns HTTP 410 when Signa no longer retains the document. The API limits `get_session_document` to 30 requests per minute and 600 requests per hour, for each business. Above these limits, the API returns HTTP 429.

Anyone who has the download link can download the document until the link expires. Do not log the link. Do not send it to a client that you do not control.

### Signa ID Release

With Signa ID, a person who is already verified releases their data to your business in a popup. Your server creates a release request, your page opens the popup, and your server exchanges the code for the data.

The release methods need an OAuth access token with the `signa-id:release` scope. API keys cannot call them. No scope bundle contains this scope, so select it by name when you create the credential. Signa ID release must also be enabled for your business.

**Note:** The SDK does not request `signa-id:release` by default. Send `oauth_scope` with that scope, preferably on a dedicated credential.

```python
signa_id_client = Blaaiz(
    client_id=os.environ['BLAAIZ_SIGNA_ID_CLIENT_ID'],
    client_secret=os.environ['BLAAIZ_SIGNA_ID_CLIENT_SECRET'],
    oauth_scope='signa-id:release',
)

# 1. Create the request. Send request_token to your page.
created = signa_id_client.signa_id.create_release_request({
    'idempotency_key': "release-user-10482",
    'purpose': "Open your trading account",
    'scopes': ["identity", "id_document", "document_images"],  # also: "address"
    'origin': "https://yourapp.com",  # the exact window.location.origin of your page
    'reference': "user_10482",  # optional
})
release_id = created['data']['data']['id']
request_token = created['data']['data']['request_token']

# 2. In your page: const { code } = await signa.requestData({ requestToken })

# 3. Exchange the code from your server. The code works one time, for 5 minutes.
exchanged = signa_id_client.signa_id.exchange_release_code(code)
release = exchanged['data']['data']['release']  # check that release['id'] == release_id
data = exchanged['data']['data']['data']

# Read the release again during the 30-day access window
current = signa_id_client.signa_id.get_release(release_id)

# Download one document image. The URL expires in 15 minutes.
image = signa_id_client.signa_id.get_release_document(release_id, data['document_images'][0]['id'])
```

The release request expires 30 minutes after the create. `get_release` returns `data` as `None` before the exchange and when `release.access.status` is not `ACTIVE`. Each create and exchange endpoint allows 30 requests each minute for each business.

**Warning:** The released data is personal data. Do not log it and do not cache it.

To check if a wallet belongs to a verified Signa ID, call `get_wallet_status`. The endpoint needs no authentication and returns no personal data.

```python
status = blaaiz.signa_id.get_wallet_status('0x1234...abcd', chain_id=8453)
print(status['data']['verified'], status['data']['level'])
```

The wallet status response is at the root of the body, with no `message` and no `data` wrapper.

### Collections

#### Initiate Open Banking Collection (EUR/GBP)

```python
collection = blaaiz.collections.initiate({
    'method': "open_banking",
    'amount': 100.00,
    'wallet_id': "wallet-id",
    'phone': "+1234567890",             # Optional
    'redirect_url': "https://example.com/callback",  # Optional
    'merchant_reference': "order-1234"  # Optional; max 255, unique per business
})

print(f'Payment URL: {collection["data"]["url"]}')
print(f'Transaction ID: {collection["data"]["transaction_id"]}')
```

#### Initiate Card Collection (NGN/USD)

```python
collection = blaaiz.collections.initiate({
    'method': "card",
    'amount': 5000,
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",       # Required for card
    'card_holder_name': "John Doe",     # Required for card
    'card_number': "4111111111111111",  # Required for card (16 digits)
    'expiry': "12/28",                  # Required for card (MM/YY)
    'cvc': "123",                       # Required for card (3 digits)
})

print(f'Payment URL: {collection["data"]["url"]}')
```

#### Crypto Collection

```python
# Get available networks
networks = blaaiz.collections.get_crypto_networks()
print(f'Available networks: {networks["data"]}')

# Initiate crypto collection
crypto_collection = blaaiz.collections.initiate_crypto({
    'amount': 100,
    'network': "ethereum",
    'token': "USDT",
    'wallet_id': "wallet-id"
})
```

#### Attach Customer to Collection

```python
attachment = blaaiz.collections.attach_customer({
    'customer_id': "customer-id",
    'transaction_id': "transaction-id"
})
```

#### Request Interac Money (CAD)

```python
money_request = blaaiz.collections.initiate_interac_money_request({
    'amount': 100,
    'email': "payer@example.com",
    'customer_name': "Jane Payer",  # Optional
    'expiry_hours': 24,             # Optional (1-120)
    'note': "Invoice 42",           # Optional
})

print(f'Reference: {money_request["reference"]}')
```

#### Accept Interac Money Request (CAD)

```python
result = blaaiz.collections.accept_interac_money_request({
    'reference_number': "interac-reference-number"
})
```

### Payouts

#### Bank Transfer Payout (NGN)

```python
payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "bank_transfer",
    'from_amount': 1000,  # OR use 'to_amount' for exact recipient amount
    'from_currency_id': "NGN",
    'to_currency_id': "NGN",
    'bank_id': "1",
    'account_number': "0123456789",
    'note': "Acme Ltd",  # Optional; appears in the transaction description (defaults to your business name if omitted)
    'merchant_reference': "order-1234",  # Optional; max 255, unique per business
})

print(f'Payout Status: {payout["data"]["transaction"]["status"]}')
```

#### Passing additional fields

The `initiate()` method forwards your payload verbatim to the API, so any field
documented in the [Blaaiz API reference](https://docs.business.blaaiz.com) can be
included even if it is not shown in the examples above. For instance, the optional
`note` field (used in the NGN example) populates the transaction description and
defaults to your business name when omitted. Extra keys are passed through unchanged
and are never stripped or rejected by the SDK.

#### Bank Transfer Payout (GBP)

```python
gbp_payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "bank_transfer",
    'from_amount': 500,
    'from_currency_id': "GBP",
    'to_currency_id': "GBP",
    'sort_code': "12-34-56",
    'account_number': "12345678",
    'account_name': "John Doe",
})
```

#### Bank Transfer Payout (EUR)

```python
eur_payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "bank_transfer",
    'from_amount': 500,
    'from_currency_id': "EUR",
    'to_currency_id': "EUR",
    'iban': "DE89370400440532013000",
    'bic_code': "COBADEFFXXX",
    'account_name': "John Doe",
})
```

#### Mobile Money Payout (KES, UGX, TZS, XOF, GHS)

Use `mobile_money` when the destination currency supports it. Get the `mobile_money_operator_id` from `momo_operators.list()`.

```python
momo_payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "mobile_money",
    'from_amount': 100,
    'from_currency_id': "USD",
    'to_currency_id': "currency-id",  # The currency ID, not the code
    'phone_number': "+254700000000",  # International format, starts with +
    'mobile_money_operator_id': "operator-id",  # Required
    'account_name': "Jane Doe",  # Required
})
```

Use the currency ID for `to_currency_id`. XOF exists for more than one country (Benin and Côte d'Ivoire), so the API rejects the code `XOF` alone unless you also send `country_id` to pick the country.

In the payout response and in webhooks, the recipient shows the phone number in `account_number` and the operator name in `bank_name`. The payout response also shows the operator code in `bank_code`.

#### Interac Payout (CAD)

```python
interac_payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "interac",
    'from_amount': 100,
    'from_currency_id': "CAD",
    'to_currency_id': "CAD",
    'email': "recipient@example.com",
    'interac_first_name': "John",
    'interac_last_name': "Doe"
})
```

#### ACH Payout (USD)

```python
ach_payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "ach",
    'from_amount': 1000,
    'from_currency_id': "USD",
    'to_currency_id': "USD",
    'type': "individual",  # or "business"
    'account_number': "123456789",
    'account_name': "John Doe",
    'account_type': "checking",  # or "savings"
    'bank_name': "Chase Bank",
    'routing_number': "021000021",
})
```

#### Wire Payout (USD)

```python
wire_payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "wire",
    'from_amount': 5000,
    'from_currency_id': "USD",
    'to_currency_id': "USD",
    'type': "individual",
    'account_number': "123456789",
    'account_name': "John Doe",
    'account_type': "checking",
    'bank_name': "Chase Bank",
    'routing_number': "021000021",
    'swift_code': "CHASUS33",
})
```

#### Crypto Payout (USD)

```python
crypto_payout = blaaiz.payouts.initiate({
    'wallet_id': "wallet-id",
    'customer_id': "customer-id",
    'method': "crypto",
    'from_amount': 100,
    'from_currency_id': "USD",
    'to_currency_id': "USD",
    'wallet_address': "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0",
    'wallet_token': "USDT",
    'wallet_network': "ethereum",
})
```

### Virtual Bank Accounts

#### Create Virtual Bank Account

```python
vba = blaaiz.virtual_bank_accounts.create({
    'wallet_id': "wallet-id",
    'account_name': "John Doe"
})

print(f'Account Number: {vba["data"]["account_number"]}')
print(f'Bank Name: {vba["data"]["bank_name"]}')
```

#### List Virtual Bank Accounts

```python
# Filter by wallet ID
vbas = blaaiz.virtual_bank_accounts.list(wallet_id="wallet-id")
print(f'Virtual Accounts: {vbas["data"]}')

# Filter by customer ID
vbas = blaaiz.virtual_bank_accounts.list(customer_id="customer-id")

# Filter by both
vbas = blaaiz.virtual_bank_accounts.list(wallet_id="wallet-id", customer_id="customer-id")
```

#### Get Virtual Bank Account

```python
vba = blaaiz.virtual_bank_accounts.get("vba-id")
print(f'Account Details: {vba["data"]}')
```

#### Close Virtual Bank Account

```python
result = blaaiz.virtual_bank_accounts.close("vba-id", reason="No longer needed")
print(f'Closed: {result["data"]}')
```

#### Get Identification Type Requirements

```python
# Using customer ID
id_types = blaaiz.virtual_bank_accounts.get_identification_type(customer_id="customer-id")

# Using country and type
id_types = blaaiz.virtual_bank_accounts.get_identification_type(country="US", type="individual")
print(f'Required ID Types: {id_types["data"]}')
```

### Wallets

#### List All Wallets

```python
wallets = blaaiz.wallets.list()
print(f'Wallets: {wallets["data"]}')
```

#### Get Specific Wallet

```python
wallet = blaaiz.wallets.get("wallet-id")
print(f'Wallet Balance: {wallet["data"]["balance"]}')
```

### Transactions

#### List Transactions

```python
transactions = blaaiz.transactions.list({
    'status': "SUCCESSFUL",              # Optional filter
    'type': "SEND_MONEY",               # Optional: SEND_MONEY, FUND_WALLET, SWAP
    'merchant_reference': "order-1234"  # Optional; filter by your merchant reference
})

print(f'Transactions: {transactions["data"]}')
```

#### Get Transaction Details

```python
transaction = blaaiz.transactions.get("transaction-id")
print(f'Transaction: {transaction["data"]}')

# The identifier accepts a transaction id, a reference, or a merchant_reference.
transaction = blaaiz.transactions.get("order-1234")
print(f'Merchant reference: {transaction["data"]["merchant_reference"]}')
```

For a collection, `source_information` carries the payer details. It has these payer keys for a bank transfer: `account_name`, `account_number`, `bank_name`, `sort_code`, `bank_swift_code`, `description`, and `narration`. Each key is always present. A key is `None` when the collection method does not supply it. Only NGN collections set `narration`. For payouts and swaps, all of these keys are `None`. For an Interac collection, `collection_email` and `collection_name` hold the payer.

### Banks & Currencies

#### List Banks

```python
banks = blaaiz.banks.list()
print(f'Available Banks: {banks["data"]}')

# Optional filters: currency, country, country_id
ngn_banks = blaaiz.banks.list({'currency': "NGN", 'country': "NG"})
```

#### List Mobile Money Operators

```python
# Filter by the destination currency ID (preferred)
operators = blaaiz.momo_operators.list({'currency_id': "currency-id"})

# Or filter by country
country_operators = blaaiz.momo_operators.list({'country_id': 1})

print(f'Operators: {operators["data"]}')  # [{id, name, code, country_id}]
```

Both filters are optional. Use the `id` of an operator as `mobile_money_operator_id` in a mobile money payout.

#### Bank Account Lookup

```python
account_info = blaaiz.banks.lookup_account({
    'account_number': "0123456789",
    'bank_id': "1"
})

print(f'Account Name: {account_info["data"]["account_name"]}')
```

#### Verify Payee (GBP Confirmation of Payee)

```python
result = blaaiz.banks.verify_payee({
    'sort_code': "123456",
    'account_number': "12345678",
    'account_name': "John Doe"
})

print(f'Matched: {result["data"]["matched"]}')
```

#### Verify IBAN (SEPA Reachability)

```python
result = blaaiz.banks.verify_iban({
    'iban': "DE89370400440532013000"
})

print(f'SEPA reachable: {result["data"]["sepa_reachable"]}')
```

#### List Currencies

```python
currencies = blaaiz.currencies.list()
print(f'Supported Currencies: {currencies["data"]}')
```

Each currency also includes `country_id` and a `country` object (`id`, `name`, `short_name`, `alt_short_name`). Use them to tell apart currencies that exist for more than one country, such as XOF.

### Fees

#### Get Fee Breakdown

```python
# Calculate fees based on amount you want to send
fee_breakdown = blaaiz.fees.get_breakdown({
    'from_currency_id': "NGN",
    'to_currency_id': "CAD",
    'from_amount': 100000
})

print(f'You send: {fee_breakdown["data"]["you_send"]}')
print(f'Recipient gets: {fee_breakdown["data"]["recipient_gets"]}')
print(f'Total fees: {fee_breakdown["data"]["total_fees"]}')

# OR calculate fees based on exact amount recipient should receive
fee_breakdown = blaaiz.fees.get_breakdown({
    'from_currency_id': "NGN",
    'to_currency_id': "CAD",
    'to_amount': 500  # Recipient gets exactly 500 CAD
})
```

### Rates

#### List Exchange Rates

```python
rates = blaaiz.rates.list()
print(f'Rates: {rates["data"]}')

# Optional search_term filter (for example a currency pair)
ngn_rates = blaaiz.rates.list({'search_term': "NGN"})
```

### Swaps

#### Swap Between Business Wallets

```python
swap = blaaiz.swaps.initiate({
    'from_business_wallet_id': "wallet-a",
    'to_business_wallet_id': "wallet-b",
    'amount': 100,
    'amount_type': "from"  # Optional: "from" (default) or "to"
})

print(f'Swap: {swap["data"]["business_swap_transaction"]}')
```

### Refunds

#### Initiate a Refund

```python
refund = blaaiz.refunds.initiate({
    'transaction_id': "transaction-id",
    'reason': "Customer request",  # Optional; max 250
    'reference': "refund-ref-01"   # Optional; max 100
})

print(f'Refund ID: {refund["data"]["id"]}')
```

#### Get a Refund

```python
refund = blaaiz.refunds.get("refund-id")
print(f'Refund status: {refund["data"]["status"]}')
```

### Webhooks

#### Register Webhooks

```python
webhook = blaaiz.webhooks.register({
    'collection_url': "https://your-domain.com/webhooks/collection",
    'payout_url': "https://your-domain.com/webhooks/payout",
    'kyc_url': "https://your-domain.com/webhooks/kyc"  # Optional Signa callback
})
```

On `webhooks.update`, `kyc_url` is also optional. If you do not send `kyc_url`, the API keeps the current value. To remove the value, send `'kyc_url': None`.

#### Get Webhook Configuration

```python
webhook_config = blaaiz.webhooks.get()
print(f'Webhook URLs: {webhook_config["data"]}')
```

#### Verify Signa Webhooks

Signa and Signa ID callbacks go to your `kyc_url`. The events are `merchant.kyc.session.completed`, `merchant.kyc.session.expired`, and `signa_id.grant.revoked`. After `signa_id.grant.revoked`, the release methods return no data for `data.release_id`.

Signa callbacks use the same `x-blaaiz-timestamp` and `x-blaaiz-signature` headers and HMAC-SHA256 scheme as collection and payout webhooks. Keep the request body raw. Then call `construct_event` (or `verify_signature`) on `blaaiz.webhooks`.

```python
event = blaaiz.webhooks.construct_event(
    raw_body,       # Raw webhook payload string
    signature,      # x-blaaiz-signature header
    timestamp,      # x-blaaiz-timestamp header
    webhook_secret  # Your API secret key
)

print(f'Verified Signa event: {event}')
```

#### Replay Webhook

```python
replay = blaaiz.webhooks.replay({
    'transaction_id': "transaction-id"
})
```

#### Simulate Interac Webhook (Non-Production Only)

```python
# Only available in non-production environments
result = blaaiz.webhooks.simulate_interac_webhook({
    'amount': 100,
    'collection_email': "sender@example.com"
})
```

## Advanced Usage

### Complete Payout Workflow

```python
complete_payout_result = blaaiz.create_complete_payout({
    'customer_data': {
        'first_name': "John",
        'last_name': "Doe",
        'type': "individual",
        'email': "john@example.com",
        'country': "NG",
        'id_type': "passport",
        'id_number': "A12345678"
    },
    'payout_data': {
        'wallet_id': "wallet-id",
        'method': "bank_transfer",
        'from_amount': 1000,  # OR 'to_amount' for exact recipient amount
        'from_currency_id': "NGN",
        'to_currency_id': "NGN",
        'bank_id': "1",
        'account_number': "0123456789",
    }
})

print(f'Customer ID: {complete_payout_result["customer_id"]}')
print(f'Payout: {complete_payout_result["payout"]}')
print(f'Fees: {complete_payout_result["fees"]}')
```

### Complete Collection Workflow

```python
complete_collection_result = blaaiz.create_complete_collection({
    'customer_data': {
        'first_name': "Jane",
        'last_name': "Smith",
        'type': "individual",
        'email': "jane@example.com",
        'country': "NG",
        'id_type': "drivers_license",
        'id_number': "ABC123456"
    },
    'collection_data': {
        'method': "card",
        'amount': 5000,
        'currency': "NGN",
        'wallet_id': "wallet-id"
    },
    'create_vba': True  # Optionally create a virtual bank account
})

print(f'Customer ID: {complete_collection_result["customer_id"]}')
print(f'Collection: {complete_collection_result["collection"]}')
print(f'Virtual Account: {complete_collection_result["virtual_account"]}')
```

### Context Manager Support

```python
with Blaaiz('your-api-key') as blaaiz:
    customers = blaaiz.customers.list()
    print(f'Total customers: {len(customers["data"])}')
```

## Error Handling

The SDK uses a custom `BlaaizError` class that provides detailed error information:

```python
from blaaiz import Blaaiz, BlaaizError

try:
    blaaiz = Blaaiz('your-api-key')
    customer = blaaiz.customers.create(invalid_data)
except BlaaizError as e:
    print(f'Blaaiz API Error: {e.message}')
    print(f'Status Code: {e.status}')
    print(f'Error Code: {e.code}')
except Exception as e:
    print(f'Unexpected Error: {str(e)}')
```

## Webhook Handling

### Webhook Signature Verification

The SDK provides built-in webhook signature verification. Blaaiz uses HMAC-SHA256 to sign webhooks with the format `timestamp.payload`.

```python
from blaaiz import Blaaiz

blaaiz = Blaaiz('your-api-key')

# Method 1: Verify signature manually
is_valid = blaaiz.webhooks.verify_signature(
    raw_body,       # Raw webhook payload string
    signature,      # x-blaaiz-signature header
    timestamp,      # x-blaaiz-timestamp header
    webhook_secret  # Your API secret key
)

if is_valid:
    print('Webhook signature is valid')
else:
    print('Invalid webhook signature')

# Method 2: Construct verified event (recommended)
try:
    event = blaaiz.webhooks.construct_event(
        payload,        # Raw webhook payload string
        signature,      # x-blaaiz-signature header
        timestamp,      # x-blaaiz-timestamp header
        webhook_secret  # Your API secret key
    )

    print(f'Verified event: {event}')
    # event['verified'] will be True
    # event['timestamp'] will contain verification timestamp
except ValueError as e:
    print(f'Webhook verification failed: {str(e)}')
```

### Flask Webhook Handler Example

```python
from flask import Flask, request, jsonify
from blaaiz import Blaaiz
import os

app = Flask(__name__)
blaaiz = Blaaiz(os.getenv('BLAAIZ_API_KEY'))

# Webhook secret (your API secret key)
WEBHOOK_SECRET = os.getenv('BLAAIZ_WEBHOOK_SECRET')

@app.route('/webhooks/collection', methods=['POST'])
def handle_collection_webhook():
    signature = request.headers.get('x-blaaiz-signature')
    timestamp = request.headers.get('x-blaaiz-timestamp')
    payload = request.get_data(as_text=True)

    try:
        # Verify webhook signature and construct event
        event = blaaiz.webhooks.construct_event(payload, signature, timestamp, WEBHOOK_SECRET)

        print(f'Verified collection event: {event}')

        # Process the collection
        # Update your database, send notifications, etc.

        return jsonify({'received': True}), 200

    except ValueError as e:
        print(f'Webhook verification failed: {str(e)}')
        return jsonify({'error': 'Invalid signature'}), 400

@app.route('/webhooks/payout', methods=['POST'])
def handle_payout_webhook():
    signature = request.headers.get('x-blaaiz-signature')
    timestamp = request.headers.get('x-blaaiz-timestamp')
    payload = request.get_data(as_text=True)

    try:
        # Verify webhook signature and construct event
        event = blaaiz.webhooks.construct_event(payload, signature, timestamp, WEBHOOK_SECRET)

        print(f'Verified payout event: {event}')

        # Process the payout completion
        # Update your database, send notifications, etc.

        return jsonify({'received': True}), 200

    except ValueError as e:
        print(f'Webhook verification failed: {str(e)}')
        return jsonify({'error': 'Invalid signature'}), 400

if __name__ == '__main__':
    app.run(debug=True)
```

## Environment Configuration

```python
import os

# Development
blaaiz_dev = Blaaiz('dev-api-key', base_url='https://api-dev.blaaiz.com')

# Production (when available)
blaaiz_prod = Blaaiz('prod-api-key', base_url='https://api.blaaiz.com')

# OAuth 2.0 with credentials from the environment
blaaiz_oauth = Blaaiz(
    client_id=os.environ['BLAAIZ_CLIENT_ID'],
    client_secret=os.environ['BLAAIZ_CLIENT_SECRET'],
    base_url='https://api.blaaiz.com',
)
```

## Best Practices

1. **Always validate customer data before creating customers**
2. **Use the fees API to calculate and display fees to users**
3. **Always verify webhook signatures using the SDK's built-in methods**
4. **Store customer IDs and transaction IDs for tracking**
5. **Handle rate limiting gracefully with exponential backoff**
6. **Use environment variables for API keys and webhook secrets**
7. **Implement proper error handling and logging**
8. **Test webhook endpoints thoroughly with signature verification**
9. **Use the context manager for automatic resource cleanup**
10. **Return appropriate HTTP status codes from webhook handlers**

## Development

To set up the development environment:

```bash
# Clone the repository
git clone https://github.com/blaaiz/blaaiz-python-sdk.git
cd blaaiz-python-sdk

# Install development dependencies
pip install -e ".[dev]"

# Run tests
pytest

# Run linting
flake8 blaaiz/
black blaaiz/

# Run type checking
mypy blaaiz/
```

## Contributing

1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests for new functionality
5. Run the test suite
6. Submit a pull request

See [RELEASING.md](RELEASING.md) for how a merged pull request becomes a release.

## License

This project is licensed under the MIT License - see the LICENSE file for details.

## Support

For support and additional documentation:
- Email: onboarding@blaaiz.com
- Documentation: https://docs.business.blaaiz.com
- Issues: https://github.com/blaaiz/blaaiz-python-sdk/issues

## Changelog

### 1.4.0

- **Rate Service** (new): added `rates.list()` for exchange rate lookups
- **Swap Service** (new): added `swaps.initiate()` to swap funds between business wallets
- **Refund Service** (new): added `refunds.initiate()` and `refunds.get()`
- **Merchant Reference**: optional `merchant_reference` (max 255, unique per business) on `payouts.initiate()` and `collections.initiate()`; also returned on transactions, filterable in `transactions.list()`, and resolvable by `transactions.get()`
- **Bank Service**:
  - Added `verify_payee()` (GBP confirmation of payee) and `verify_iban()` (SEPA reachability)
  - `list()` now accepts optional `currency`/`country`/`country_id` filters
- **Collection Service**:
  - Added `initiate_interac_money_request()` (CAD)
  - Fixed `initiate()`: requires only `method`, `amount`, `wallet_id`; card method additionally requires the card fields; the non-existent `currency` field is no longer required
  - `initiate_crypto()` now validates `amount`, `wallet_id`, `network`, `token`
  - `get_crypto_networks()` now accepts an optional `transaction_type` filter
- **Customer Service**:
  - Fixed `create()`: `id_type`/`id_number` are individual-only; business now requires `business_name`, `registration_number`, `incorporation_country`
  - Fixed `upload_files()` to use POST (was PUT)
  - Added `identity_back` file category to `upload_file_complete()`
  - Added `submit()`, `upgrade_kyb_scope()`, `delete_owner()`, `get_owner_file_presigned_url()`, `upload_owner_files()`, and document CRUD (`list_documents()`, `get_document()`, `get_document_presigned_url()`, `create_document()`, `update_document()`, `delete_document()`)

### 1.2.0
- **OAuth 2.0 Authentication**:
  - Added OAuth 2.0 client-credentials support: construct the client with `client_id`/`client_secret` to authenticate via Bearer tokens fetched from `/oauth/token`
  - Fully backward compatible: the legacy `api_key` authentication still works unchanged
  - When both `api_key` and OAuth credentials are provided, OAuth is preferred

### 1.1.0
- **Customer Service**:
  - Added `list_beneficiaries()` method to list customer beneficiaries
  - Added `get_beneficiary()` method to get specific beneficiary
  - Updated `create()` validation: `first_name`/`last_name` now only required for individuals
- **Collection Service**:
  - Added `accept_interac_money_request()` method for CAD Interac transfers
  - Updated `initiate()` required fields: now requires `customer_id` and `currency`
- **Payout Service**:
  - Added `customer_id` as required field
  - Added support for `to_amount` as alternative to `from_amount`
  - Added currency-specific validation for bank transfers (NGN, GBP, EUR)
  - Added ACH payout support (USD)
  - Added Wire payout support (USD)
  - Added Crypto payout support (USD)
- **Virtual Bank Account Service**:
  - Added `close()` method to close virtual bank accounts
  - Added `get_identification_type()` method for ID type requirements
  - Updated `list()` to support `customer_id` filter parameter
- **Webhook Service**:
  - Added `simulate_interac_webhook()` method (non-production only)
  - Updated `verify_signature()` to use timestamp-based verification
  - Updated `construct_event()` to require timestamp parameter
- **Fees Service**:
  - Added support for `to_amount` as alternative to `from_amount`

### 1.0.0
- Initial release
- Support for all Blaaiz API endpoints
- Comprehensive error handling
- Webhook signature verification
- File upload functionality
- Complete workflow helpers
- Context manager support
