<!-- canonical: https://docs.costplus.io/docs/sdks/python -->

> Official Python SDK for the Cost+ payment gateway
Official Python SDK for the Cost+ payment gateway. Simplifies the HPP (Hosted Payment Page) redirect flow, HMAC payload signing, and webhook verification.

## Features

- **Minimal dependencies** — only `requests`
- Full type hints and frozen dataclass return types
- HMAC-SHA256 signature generation and constant-time verification
- Webhook parsing + API-based order verification
- Tested across Python 3.10, 3.11, and 3.12

## Requirements

- Python 3.10 or later
- A Cost+ merchant account — [dashboard.costplus.io](https://dashboard.costplus.io/)

## Installation

```bash
pip install nopayn-sdk
```

## Quick Start

### 1. Initialise the Client

```python
from nopayn import NoPaynClient

client = NoPaynClient(
    api_key="your-api-key",
    merchant_id="your-project",
)
```

### 2. Create a Payment and Redirect to the HPP

```python
result = client.generate_payment_url(
    amount=1295,             # €12.95 in cents
    currency="EUR",
    merchant_order_id="ORDER-001",
    description="Premium Widget",
    return_url="https://shop.example.com/success",
    failure_url="https://shop.example.com/failure",
    webhook_url="https://shop.example.com/webhook",
    locale="en-GB",
    expiration_period="PT30M",
)

# Redirect the customer
# result.order_url   → HPP (customer picks payment method)
# result.payment_url → direct link to the first transaction's payment method
# result.signature   → HMAC-SHA256 for verification
# result.order_id    → NoPayn order UUID
```

### 3. Handle the Webhook (Flask Example)

```python
@app.route("/webhook", methods=["POST"])
def webhook():
    verified = client.verify_webhook(request.get_data(as_text=True))

    print(verified.order.status)  # 'completed', 'cancelled', etc.
    print(verified.is_final)      # True when the order won't change

    if verified.order.status == "completed":
        # Fulfil the order
        pass

    return "", 200
```

## API Reference

### `NoPaynClient(api_key, merchant_id, base_url?)`

| Parameter | Type | Required | Default |
|---|---|---|---|
| `api_key` | `str` | Yes | — |
| `merchant_id` | `str` | Yes | — |
| `base_url` | `str` | No | `https://api.nopayn.co.uk` |

### `client.create_order(**kwargs) -> Order`

Creates an order via `POST /v1/orders/`.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `amount` | `int` | Yes | Amount in smallest currency unit (cents) |
| `currency` | `str` | Yes | ISO 4217 code (`EUR`, `GBP`, `USD`, `NOK`, `SEK`) |
| `merchant_order_id` | `str` | No | Your internal order reference |
| `description` | `str` | No | Order description |
| `return_url` | `str` | No | Redirect after successful payment |
| `failure_url` | `str` | No | Redirect on cancel/expiry/error |
| `webhook_url` | `str` | No | Async status-change notifications |
| `locale` | `str` | No | HPP language (`en-GB`, `de-DE`, `nl-NL`, etc.) |
| `payment_methods` | `list[str]` | No | Filter HPP methods |
| `expiration_period` | `str` | No | ISO 8601 duration (`PT30M`) |

**Available payment methods:** `credit-card`, `apple-pay`, `google-pay`, `vipps-mobilepay`

### `client.get_order(order_id) -> Order`

Fetches order details via `GET /v1/orders/{id}/`.

### `client.create_refund(order_id, amount, description?) -> Refund`

Issues a full or partial refund via `POST /v1/orders/{id}/refunds/`.

### `client.generate_payment_url(**kwargs) -> PaymentUrlResult`

Convenience method that creates an order and returns:

```python
PaymentUrlResult(
    order_id="...",       # NoPayn order UUID
    order_url="...",      # HPP URL
    payment_url="...",    # Direct payment URL (first transaction)
    signature="...",      # HMAC-SHA256 of amount:currency:order_id
    order=Order(...),     # Full order object
)
```

### `client.generate_signature(amount, currency, order_id) -> str`

Generates an HMAC-SHA256 hex signature.

### `client.verify_signature(amount, currency, order_id, signature) -> bool`

Constant-time verification of an HMAC-SHA256 signature.

### `client.verify_webhook(raw_body) -> VerifiedWebhook`

Parses the webhook body, then calls `GET /v1/orders/{id}/` to verify the actual status.

### Standalone HMAC Utilities

```python
from nopayn import generate_signature, verify_signature

sig = generate_signature("your-api-key", 1295, "EUR", "order-uuid")
ok = verify_signature("your-api-key", 1295, "EUR", "order-uuid", sig)
```

## Data Types

All API responses are returned as frozen dataclasses:

| Class | Fields |
|---|---|
| `Order` | `id`, `amount`, `currency`, `status`, `created`, `modified`, `transactions`, `description`, `merchant_order_id`, `return_url`, `failure_url`, `order_url`, `completed` |
| `Transaction` | `id`, `amount`, `currency`, `status`, `created`, `modified`, `payment_method`, `payment_url`, `expiration_period` |
| `Refund` | `id`, `amount`, `status` |
| `PaymentUrlResult` | `order_id`, `order_url`, `payment_url`, `signature`, `order` |
| `VerifiedWebhook` | `order_id`, `order`, `is_final` |

## Error Handling

```python
from nopayn import ApiError, NoPaynError, WebhookError

try:
    client.create_order(amount=100, currency="EUR")
except ApiError as exc:
    print(exc.status_code)  # 401, 400, etc.
    print(exc.error_body)   # Raw API error response
except NoPaynError as exc:
    print(exc)              # Network or parsing error
```

## Order Statuses

| Status | Final? | Description |
|---|---|---|
| `new` | No | Order created |
| `processing` | No | Payment in progress |
| `completed` | Yes | Payment successful — deliver the goods |
| `cancelled` | Yes | Payment cancelled by customer |
| `expired` | Yes | Payment link timed out |
| `error` | Yes | Technical failure |

## Webhook Best Practices

1. **Always verify via the API** — the webhook payload only contains the order ID, never the status. The SDK's `verify_webhook()` does this automatically.
2. **Return HTTP 200** to acknowledge receipt. Any other code triggers up to 10 retries (2 minutes apart).
3. **Implement a backup poller** — for orders older than 10 minutes that haven't reached a final status, poll `get_order()` as a safety net.
4. **Be idempotent** — you may receive the same webhook multiple times.

## Test Cards

Use these cards in Cost+ test mode (sandbox website):

| Card | Number | Notes |
|---|---|---|
| Visa (success) | `4111 1111 1111 1111` | Any CVV |
| Mastercard (success) | `5544 3300 0003 7` | Any CVV |
| Visa (declined) | `4111 1111 1111 1105` | Do Not Honor |
| Visa (insufficient funds) | `4111 1111 1111 1151` | Insufficient Funds |

Use any future expiry date and any 3-digit CVC.

## Demo App

A Flask-based demo app is included in the [GitHub repository](https://github.com/NoPayn/python-sdk) for testing the full payment flow.

## Support

Need help? Reach out to our support team at **support@costplus.io**.
