# zyndpay

Official ZyndPay Python SDK — accept USDT TRC20 payments with a few lines of code.

[![PyPI version](https://img.shields.io/pypi/v/zyndpay)](https://pypi.org/project/zyndpay/)
[![Python](https://img.shields.io/pypi/pyversions/zyndpay)](https://pypi.org/project/zyndpay/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

---

## Requirements

- Python 3.8+
- A ZyndPay account and API key

---

## Installation

```bash
pip install zyndpay
```

---

## Quickstart

```python
from zyndpay import ZyndPay

zyndpay = ZyndPay("zyp_live_sk_...")

# Create a payment request
payin = zyndpay.payins.create(amount="100")
print(payin["depositAddress"])  # Send USDT TRC20 here
print(payin["paymentPageUrl"])  # Redirect your customer here

# Check your balance
balance = zyndpay.balances.get()
print(balance["available"])  # e.g. "97.00"
```

---

## Configuration

```python
zyndpay = ZyndPay(
    api_key="zyp_live_sk_...",              # required
    webhook_secret="whsec_...",             # optional — needed for webhook verification
    base_url="https://api.zyndpay.com/v1",  # optional — override for self-hosted
    timeout=30,                             # optional — seconds (default: 30)
    max_retries=2,                          # optional — retries on network errors (default: 2)
)
```

### API key types

| Prefix | Type |
|---|---|
| `zyp_live_sk_` | Live secret key |
| `zyp_live_pk_` | Live publishable key |
| `zyp_test_sk_` | Sandbox secret key |
| `zyp_test_pk_` | Sandbox publishable key |

---

## Payins

### Create a payin

```python
payin = zyndpay.payins.create(
    amount="100",                      # USDT amount (minimum 20)
    external_ref="order_9f8e7d",       # your internal order ID (optional)
    expires_in_seconds=3600,           # 1 hour — default is 24h (optional)
    metadata={"user_id": "usr_123"},   # stored as-is (optional)
    success_url="https://yoursite.com/success",
    cancel_url="https://yoursite.com/cancel",
)

print(payin["id"])              # "pay_..."
print(payin["depositAddress"])  # TRC20 address to receive payment
print(payin["amount"])          # "100"
print(payin["fee"])             # "3.00"
print(payin["netAmount"])       # "97.00"
print(payin["status"])          # "AWAITING_PAYMENT"
print(payin["expiresAt"])       # ISO timestamp
```

### Get a payin

```python
payin = zyndpay.payins.get("pay_abc123")
```

### List payins

```python
result = zyndpay.payins.list(status="CONFIRMED", page=1, limit=20)
for payin in result["data"]:
    print(payin["id"], payin["status"])

meta = result["meta"]
print(meta["total"], meta["totalPages"])
```

### Payin statuses

| Status | Description |
|---|---|
| `PENDING` | Just created |
| `AWAITING_PAYMENT` | Deposit address assigned, waiting for funds |
| `CONFIRMING` | Payment detected, waiting for confirmations |
| `CONFIRMED` | Payment confirmed — balance credited |
| `EXPIRED` | Payment window elapsed |
| `OVERPAID` | More than expected was sent |
| `UNDERPAID` | Less than expected was sent |
| `FAILED` | Processing failed |

---

## Sandbox / Test Mode

Use your sandbox API key (`zyp_test_sk_...`) and pass `sandbox=True` when creating a payin. Then call `simulate` to instantly confirm it without real funds.

```python
zyndpay = ZyndPay("zyp_test_sk_...")

# Create a sandbox payin
payin = zyndpay.payins.create(amount="100", sandbox=True)

# Instantly simulate confirmation
confirmed = zyndpay.payins.simulate(payin["id"])
print(confirmed["status"])  # "CONFIRMED"
```

---

## Withdrawals

### Request a withdrawal

```python
withdrawal = zyndpay.withdrawals.create(
    amount="50",                          # USDT amount
    idempotency_key="idempotency-key-123" # optional
)

print(withdrawal["status"])     # "PENDING_REVIEW"
print(withdrawal["fee"])        # "2.00" (flat $2 fee)
print(withdrawal["netAmount"])  # "48.00"
```

### Get / list withdrawals

```python
withdrawal = zyndpay.withdrawals.get("wdr_abc123")

result = zyndpay.withdrawals.list(status="CONFIRMED", page=1, limit=20)
```

### Cancel a withdrawal

```python
zyndpay.withdrawals.cancel("wdr_abc123")  # only while PENDING_REVIEW
```

### Withdrawal statuses

| Status | Description |
|---|---|
| `PENDING_REVIEW` | Awaiting admin approval |
| `APPROVED` | Approved, queued for processing |
| `PROCESSING` | Being broadcast to blockchain |
| `BROADCAST` | Transaction sent |
| `CONFIRMED` | On-chain confirmed |
| `REJECTED` | Rejected by admin |
| `CANCELLED` | Cancelled by merchant |
| `FAILED` | Broadcast failed |

---

## Transactions

```python
# Get a single transaction
tx = zyndpay.transactions.get("txn_abc123")

# List with filters
result = zyndpay.transactions.list(
    type="PAYIN",           # "PAYIN" | "PAYOUT"
    status="CONFIRMED",
    from_date="2026-01-01",
    to_date="2026-03-31",
    page=1,
    limit=50,
)
```

---

## Balances

```python
balance = zyndpay.balances.get()
print(balance["currency"])   # "USDT_TRC20"
print(balance["available"])  # spendable balance
print(balance["pending"])    # in-flight / unconfirmed
print(balance["total"])      # available + pending
```

---

## Webhooks

ZyndPay sends signed webhook events to your endpoint. Always verify the signature before processing.

### Verify a webhook (Flask example)

```python
from flask import Flask, request, abort
from zyndpay import ZyndPay

zyndpay = ZyndPay(
    api_key="zyp_live_sk_...",
    webhook_secret="whsec_...",
)

app = Flask(__name__)

@app.route("/webhooks/zyndpay", methods=["POST"])
def handle_webhook():
    # IMPORTANT: use raw body — do not parse JSON before verifying
    payload = request.get_data(as_text=True)
    signature = request.headers.get("X-ZyndPay-Signature", "")

    try:
        event = zyndpay.webhooks.verify(payload, signature)
    except ValueError as e:
        return str(e), 400

    if event["type"] == "payin.confirmed":
        print("Payment confirmed:", event["data"])
    elif event["type"] == "withdrawal.confirmed":
        print("Withdrawal confirmed:", event["data"])

    return {"received": True}, 200
```

### Webhook event types

| Event | Trigger |
|---|---|
| `payin.created` | Payin created |
| `payin.confirming` | Payment detected on-chain |
| `payin.confirmed` | Payment fully confirmed |
| `payin.expired` | Payin expired before payment |
| `payin.overpaid` | More than expected received |
| `payin.underpaid` | Less than expected received |
| `payin.failed` | Processing error |
| `withdrawal.requested` | Withdrawal created |
| `withdrawal.approved` | Approved by admin |
| `withdrawal.rejected` | Rejected by admin |
| `withdrawal.broadcast` | Sent to blockchain |
| `withdrawal.confirmed` | On-chain confirmed |
| `withdrawal.failed` | Broadcast failed |

---

## Error Handling

All SDK errors inherit from `ZyndPayError` and include `status_code` and an optional `request_id`.

```python
from zyndpay import (
    ZyndPayError,
    AuthenticationError,
    ValidationError,
    NotFoundError,
    RateLimitError,
)

try:
    payin = zyndpay.payins.create(amount="5")  # below minimum
except ValidationError as e:
    print("Bad request:", e)          # "amount must be >= 20"
    print("Status code:", e.status_code)  # 400
except AuthenticationError:
    print("Invalid API key")
except NotFoundError:
    print("Resource not found")
except RateLimitError as e:
    print(f"Rate limited, retry after {e.retry_after}s")
except ZyndPayError as e:
    print(f"API error {e.status_code}: {e}")
```

---

## License

MIT — see [LICENSE](LICENSE)
