Metadata-Version: 2.4
Name: deepayment
Version: 0.5.0
Summary: Merchant open API SDK (Ed25519 / RFC 9421 signing, X25519 sealed box body encryption)
Author-email: DEEPayment <support@deepayment.com>
License: Apache-2.0
Project-URL: Homepage, https://github.com/deepayment/sdk-python
Project-URL: Repository, https://github.com/deepayment/sdk-python
Project-URL: Issues, https://github.com/deepayment/sdk-python/issues
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pynacl>=1.5
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# DEEPayment SDK for Python

Python SDK for the merchant open API. Signing, digesting and body encryption are
handled by the SDK; merchants never assemble `Signature-Input`, `Content-Digest`
or the sealed box envelope themselves.

The SDK is optional. You may implement the same wire protocol with an approved crypto library and your existing framework; see [direct integration](./protocol/direct-integration.md).

## Protocol

[`protocol/`](https://github.com/deepayment/sdk-python/tree/main/protocol/) is the source of truth, shared with the Go / JavaScript /
PHP / Java SDKs through one set of test vectors:

| Item | Approach |
| --- | --- |
| Signature | Ed25519 + fixed RFC 9421 profile |
| Digest | RFC 9530 `Content-Digest` (sha-256) |
| POST body | X25519 sealed box envelope; the digest covers the sealed body |
| GET | No encryption, no body; only the public query and access key are signed |
| Webhook | Platform Ed25519 signature, plaintext body |
| Merchant identity | Located by `Merchant-Access-Key` only; signature params carry **no keyId** |

## Installation

```
pip install deepayment
```

Runtime dependency: pynacl. Python 3.10 or newer.

## Configuration

Six values; only the private key is generated on the merchant side, and the
platform never receives it:

| Field | Meaning |
| --- | --- |
| `base_url` | HTTPS platform origin, without `/api/v1` |
| `access_key` | Merchant access key issued by the platform |
| `merchant_private_key_base64` | Merchant Ed25519 private key, **kept on the merchant side only** |
| `platform_body_key_id` | Current platform body key id (per deployment) |
| `platform_body_public_key_base64` | Platform X25519 public key used to seal POST bodies |
| `platform_webhook_public_keys` | Platform webhook Ed25519 public keys, indexed by keyId |

`Client` rejects non-HTTPS base URLs. Synchronous responses remain plaintext JSON
and rely on HTTPS/TLS for confidentiality and integrity.

## Quick start

```python
from deepayment import APIError, Client, CreatePaymentReq, RequestError, ResponseError, TransportError

client = Client(
    base_url="https://panama.deepayment.com",          # production origin; no /api/v1
    access_key="mak_live_xxx",
    merchant_private_key_base64=merchant_private_key,          # merchant Ed25519 private key, base64
    platform_body_key_id="body_20260827_01",
    platform_body_public_key_base64=platform_body_public_key,  # platform X25519 public key, base64
    platform_webhook_public_keys={                             # keyId -> platform Ed25519 public key, base64
        "pwhk_20260827_01": platform_webhook_public_key,
    },
)

order = client.create_payment(CreatePaymentReq(
    merchantOrderNo="M20260101001",
    currency="BRL",
    amount="100.00",                                   # decimal string, never a float
    paymentMethod={"code": "PIX", "pix": {"payerName": "Joao Silva"}},
    webhookUrl="https://merchant.example/webhook/payments",
))
print(order.orderNo, order.status, order.action.url)
```

### Two kinds of failure, opposite handling

| Error | Meaning | Handling |
| --- | --- | --- |
| `RequestError` | Rejected **before it was sent** (local validation, a bad parameter, or a request the SDK could not encode or sign) | Safe to mark failed; fix the request and retry under the same `merchantOrderNo` |
| `TransportError` | Handed to the transport, no usable response (connection failure, timeout, interrupted read) | Outcome unknown; **never mark a payout failed**. Query by `merchantOrderNo`, or resend the identical request under the same number |
| `APIError` | The gateway returned a business error (`msg` / `message` / `trace_id`) | Branch on `msg`. `IDEMPOTENCY_CONFLICT`: the number is taken but the platform could not return its order, query that number and keep querying rather than switching numbers. `CHANNEL_ERROR`: the order may already exist, query by `merchantOrderNo` first and reuse that number only once the query returns `ORDER_NOT_FOUND`. `CHANNEL_BUSY`: refused before the order was created, so resend the same number after a back-off; this is the only channel error that needs no query first |
| `ResponseError` | The gateway or CDN returned something that is not an envelope (HTML 502, ...) | Outcome unknown; query before deciding |
| `ResponseTooLargeError` | A response arrived but exceeded the size limit and was discarded | Outcome unknown; the order was most likely created, query before deciding |
| Anything else | An unexpected error; assume the request may have arrived | Outcome unknown; query before deciding |

**`merchantOrderNo` is the only key that prevents a duplicate order.** A second
create with the same number never creates a second order: the platform answers
with the original order, or with `IDEMPOTENCY_CONFLICT` when it recognises the
number as taken but cannot return that order. The idempotency key travels with the request for tracing and
is **not** a deduplication key.

Two rules follow:

- After an unknown outcome, never allocate a new `merchantOrderNo`. Query the
  existing one, or resend the same request under the same number.
- A resend must carry identical parameters. The platform returns the original
  order without comparing fields, so a changed amount or account silently has no
  effect. To change anything, use a new `merchantOrderNo` and reconcile the
  original order first.

The SDK validates locally before signing (top-level required fields and formats,
method shape and required extras); the rules are defined in
[`protocol/merchant-api.md`](https://github.com/deepayment/sdk-python/blob/main/protocol/merchant-api.md#client-side-validation).
Format checks (phone length, e-mail, ...) stay with the gateway on purpose so the
SDK cannot drift from it.

### Next steps

Queries, idempotent retries and webhook verification are covered by the platform
documentation at <https://docs.deepayment.com>; its examples map one to one onto this
SDK. The wire protocol is in
[`protocol/webhook.md`](https://github.com/deepayment/sdk-python/blob/main/protocol/webhook.md) and
[`protocol/merchant-api.md`](https://github.com/deepayment/sdk-python/blob/main/protocol/merchant-api.md). Key points:

- After a create timeout, query by `merchantOrderNo` first instead of sending a
  new order; a deliberate retry repeats the same call with the same `merchantOrderNo`.
- For webhooks, pass the method, path, headers and the **raw, unparsed body bytes**
  to `client.parse_payment_webhook(method=, path=, headers=, body=)`; the SDK checks
  the digest, event id, time window and Ed25519 signature. Return 2xx once processed
  and deduplicate by `eventId`.

### Supplementing a payment with the payer's transfer reference

When a payment stays `PROCESSING` and the payer has a transfer reference (for example
a 12-digit UPI UTR), submit it with `supplement_payment` so the platform can have the
channel match the transfer. Identify the order by exactly one of `order_no` or
`merchant_order_no`. The request is signed and encrypted like `create_payment` and
returns a `PaymentOrder`.

```python
order = client.supplement_payment("123456789012", order_no="P20260101001")
```

Returning only means the channel accepted the reference; the final status still
arrives by webhook or query. The reference format is validated by the platform, not
the SDK. `APIError` `msg` values to expect: `INVALID_FIELD` (bad or missing
`tradeNo`), `ORDER_NOT_FOUND`, `IDEMPOTENCY_CONFLICT` (the order is no longer
`PROCESSING`), `UNSUPPORTED_METHOD`, and `CHANNEL_ERROR` (the channel did not accept
the reference; the order stays `PROCESSING`).

## Amounts

Amounts, fees and rates in requests, responses, webhooks, balances, rates and
receipts are **always decimal strings** such as `"100.00"`. Never use floats.

## Order status

There are exactly six external statuses: `PENDING` `PROCESSING` `SUCCEEDED`
`FAILED` `EXPIRED` `CANCELED`.

## Troubleshooting

| Situation | Action |
| --- | --- |
| Create request timed out | Query by `merchantOrderNo`; do not send a new order |
| Deliberate retry | Call the same method again with the same `merchantOrderNo` and identical parameters; the SDK regenerates the nonce on every request |
| Signature rejected | Check the server clock, the access key, and that the merchant private key matches the public key registered with the platform |
| Body decryption failed | Check that `platform_body_key_id` and the public key belong to the current environment |
| Webhook signature rejected | Check that the webhook key set contains the `keyid` from the header |

## Tests

```
python -m pip install -e '.[dev]'
python -m pytest -q
```

The tests assert directly against the [`protocol/testdata`](https://github.com/deepayment/sdk-python/tree/main/protocol/testdata/)
vectors: `Signature-Input`, the signature base and the signature value are compared
byte for byte, and body encryption is verified by opening ciphertext produced by
the reference implementation.

## Webhook order matching

Save the platform `orderNo` returned by creation under the expected merchant. After verification, match that number to the saved order and check order type, merchant order number, currency and applicable amounts before updating state. Do not credit or refund using `merchantOrderNo` alone. For an unknown platform order, reliably queue the event and query by `orderNo` with your own credentials before applying it. Deduplicate by `eventId`, commit the business update or durable queue acceptance before returning 2xx, and retry queued failures internally. See [webhook processing](./protocol/webhook.md#order-ownership-before-state-changes).

## Aggregate hosted checkout

Create a payment through the existing payment endpoint with a specific `currency`
and `paymentMethod: {"code":"ALL"}`. Do not include method-specific payloads.
The payer selects an available method and provides any required payer details on
`action.url`. Availability depends on the merchant configuration and enabled
payment capabilities; an SDK rule does not imply that a currency is enabled.

Keep the complete `action.url`, including its fragment. Treat it as a private
checkout link. Queries keep returning that entry URL after selection, while the
order's payment method changes from `ALL` to the selected method. A successful
create or browser return does not prove payment: verify the signed webhook or
query the order. On timeout, query or retry with the same merchant order number
and unchanged business fields. `ALL` is unavailable for payouts and subscriptions.
