Metadata-Version: 2.5
Name: myntkaup
Version: 0.1.0
Summary: Python client for the Myntkaup public API: signed requests, retries, typed errors and pagination.
Project-URL: Homepage, https://developers.myntkaup.is
Project-URL: Source, https://github.com/myntkaup/myntkaup-public-api/tree/main/python
License-Expression: MIT
License-File: LICENSE
Keywords: api,bitcoin,crypto,myntkaup,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: cryptography>=41
Requires-Dist: httpx<1,>=0.25
Provides-Extra: dev
Requires-Dist: anyio>=4; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# myntkaup

The Python client for the [Myntkaup public API](https://developers.myntkaup.is). It signs every request, retries what is safe to retry, sends idempotency keys, follows pagination cursors, and raises typed exceptions.

- Python 3.10 or later. Typed (`py.typed`).
- Two dependencies: `httpx` and `cryptography`.
- `Myntkaup` (synchronous) and `AsyncMyntkaup` (asyncio), with the same methods.

## Install

```sh
pip install myntkaup
```

## Configure

Create a key in the Myntkaup webapp under **Settings → API keys**. You get a key id (`mk_live_…`, or `mk_test_…` on staging) and a private key (`mk_sk_…`). Myntkaup stores only the public key, so keep the private key safe, e.g. in a secrets manager.

```python
from myntkaup import Myntkaup

client = Myntkaup()  # reads MYNTKAUP_API_KEY_ID and MYNTKAUP_API_PRIVATE_KEY
# or
client = Myntkaup(key_id="mk_live_…", private_key="mk_sk_…")
```

| Option        | Default                                                                                         |
| ------------- | ----------------------------------------------------------------------------------------------- |
| `key_id`      | `MYNTKAUP_API_KEY_ID`                                                                           |
| `private_key` | `MYNTKAUP_API_PRIVATE_KEY`                                                                      |
| `base_url`    | `https://public-api.myntkaup.is`; `https://public-api.staging.myntkaup.is` for a `mk_test_` key |
| `timeout`     | `30.0` seconds, per attempt                                                                     |
| `max_retries` | `2`                                                                                             |
| `transport`   | an `httpx` transport, e.g. `httpx.MockTransport` in tests                                       |

The client holds a connection pool: use it as a context manager (`with Myntkaup() as client:`) or call `client.close()` when done.

Responses are plain dicts, exactly as the API sends them, with snake_case keys, so the JSON in the API reference applies as is. They are typed as `TypedDict`s (`myntkaup.Quote`, `myntkaup.Trade`, …). Amounts are decimal strings (`"0.00104210"`); pass amounts as strings or `decimal.Decimal`, never as floats. Use `Decimal(value)` to calculate with them.

## Examples

### Status, account and market data

```python
status = client.status()  # unsigned; status["server_time"] is the server clock
me = client.me()  # the account and this key
print(me["account"]["tier"], me["key"]["scopes"])

assets = client.assets.list()["data"]
prices = client.prices.list()  # every asset at your tier, in USDC
btc = client.prices.get("BTC")  # {"asset", "quote_currency", "buy", "sell", "updated_at"}
```

### Balances and limits

```python
balances = client.balances.list()
for balance in balances["data"]:
    print(balance["asset"], balance["available"], balance["held"])

limits = client.limits.get()
print(limits["trade"]["daily_volume_used_usdc"], limits["rate_limits"]["classes"]["quote"])
```

### Trading

The quick way: `buy` and `sell` create a quote and accept it.

```python
trade = client.buy(asset="BTC", quote_amount="100.00")  # spend 100 USDC
sold = client.sell(asset="BTC", base_amount="0.001")  # sell 0.001 BTC

if trade["status"] == "filled":
    print(f"Bought {trade['base_amount']} BTC at {trade['price']}")
```

The helpers return the trade whatever its `status`, so check it: a trade can be `failed` (see `failure_reason`). A trade still `pending` (the API answered 202) is polled with `trades.get` for up to 30 seconds. If it is still pending after that, or the key can't read trades (`trades.get` needs the `transactions:read` scope), you get the pending trade back: look it up later with `client.trades.get(trade["id"])`.

Step by step:

```python
quote = client.quotes.create(asset="BTC", side="buy", quote_amount="100.00")
# or asset="BTC", side="sell", base_amount="0.001": exactly one of the two
print(quote["price"], quote["base_amount"], quote["expires_at"])  # valid for about 10 seconds

trade = client.trades.create(quote_id=quote["id"])  # sends an Idempotency-Key
quote = client.quotes.get(quote["id"])  # status: open, accepted, expired, filled or failed
```

`trades.create` sends a random `Idempotency-Key` (UUID v4) unless you give one, and reuses it on its retries, so a retried request never trades twice. Give your own to make retries across restarts safe:

```python
client.trades.create(quote_id=quote["id"], idempotency_key=f"order-{order_id}")
```

### History

```python
page = client.trades.list(asset="BTC", side="buy", limit=100)
print(page["data"], page["next_cursor"])  # pass next_cursor as cursor for the next page

trade = client.trades.get("tr_01JB8X2Q4M7Z3V9K5T1N6R0P2W")

# list_all follows next_cursor for you, one page at a time.
from datetime import datetime, timezone

for entry in client.transactions.list_all(
    from_="2026-01-01T00:00:00Z",  # from_ is sent as from
    to=datetime.now(timezone.utc),  # aware datetimes are sent as ISO 8601 UTC
    type="trade",
    lang="is",  # labels in Icelandic; en is the default
):
    print(entry["created_at"], entry["label"], entry["changes"])

entry = client.transactions.get("tx_01JB8X2Q4M7Z3V9K5T1N6R0P2W", lang="en")
```

`from` is a Python keyword, so the filter is `from_`. `from_` and `to` may be at most 366 days apart. `limit` is 1 to 100 (default 50).

### Gift cards

```python
card = client.giftcards.create(asset="BTC", amount="0.0005", recipient_name="Anna")
print(card["code"])  # treat it like cash

pdf = client.giftcards.pdf(card["id"])  # bytes
with open(f"{card['id']}.pdf", "wb") as file:
    file.write(pdf)

page = client.giftcards.list(status="unredeemed")
one = client.giftcards.get(card["id"])
for giftcard in client.giftcards.list_all():
    print(giftcard["id"], giftcard["status"])
```

Creating gift cards needs the `giftcards:create` scope. Like trades, `giftcards.create` sends an `Idempotency-Key` unless you give one.

### Withdrawals

To a bank account: one you have withdrawn to before, or a new Icelandic account in your own name. Bank withdrawals are always from your USDC balance; Myntkaup pays out in krónur, or in US dollars to an Arion foreign-currency account (03xx-38-…).

```python
accounts = client.bank_accounts.list()["data"]  # most recently used first
payout = client.withdrawals.create_bank(
    amount="250.00",  # USDC, taken from the balance, fee included
    bank_account=accounts[0]["account_number"],  # or a new one, e.g. "0133-26-012345"
)
print(payout["status"])  # pending: paid out on a business day
```

To a crypto address. The account's email gets a confirmation link, and nothing is sent, or held, until it is opened (within 30 minutes):

```python
withdrawal = client.withdrawals.create_crypto(
    asset="BTC",
    amount="0.01",  # fee included
    address="bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
    travel_rule={"address_owner": "self", "wallet_type": "hosted", "vasp": "kraken"},  # your Kraken account
)
print(withdrawal["status"], withdrawal["confirm_by"])  # awaiting_confirmation, until when the link works

# After the link is opened: pending, then completed with tx_id. Unconfirmed: expired.
latest = client.withdrawals.get(withdrawal["id"])
```

`travel_rule` says who receives the funds. To someone else's wallet, name them:

```python
travel_rule = {
    "address_owner": "other",
    "wallet_type": "self_hosted",  # or "hosted", with vasp (vasp_name when vasp is "other")
    "beneficiary": {"type": "individual", "full_name": "Anna Þórsdóttir"},
    # a company: {"type": "company", "company_name": "Dæmi ehf.", "registration_number": "5501012340"}
}
```

Give `network` for an asset on several networks (USDC: `ethereum`, the default, or `solana`), and `memo` for XRP (destination tag) and HBAR.

```python
page = client.withdrawals.list(type="crypto", status="pending")
for entry in client.withdrawals.list_all(from_="2026-01-01T00:00:00Z"):
    print(entry["id"], entry["type"], entry["status"], entry["amount"])
```

Withdrawing needs the `withdrawals:create` scope, which can also list bank accounts and read withdrawals (as `account:read` and `transactions:read` can). Both create methods send an `Idempotency-Key` unless you give one. When withdrawals are switched off for the API (`withdrawals_enabled` in `client.status()`), they fail with `forbidden`; an asset that can't be withdrawn on that network fails with `asset_not_withdrawable`.

### Async

`AsyncMyntkaup` has the same methods as coroutines; `list_all` returns an async iterator.

```python
import asyncio

from myntkaup import AsyncMyntkaup


async def main() -> None:
    async with AsyncMyntkaup() as client:
        balances, prices = await asyncio.gather(client.balances.list(), client.prices.list())
        trade = await client.buy(asset="BTC", quote_amount="25.00")
        async for entry in client.transactions.list_all(type="trade"):
            print(entry["label"])


asyncio.run(main())
```

## Errors

Every non-2xx answer raises an `ApiError` subclass. Match on `code`, never on `message`.

| Exception             | Status  | Examples of `code`                                                      |
| --------------------- | ------- | ----------------------------------------------------------------------- |
| `ValidationError`     | 400/422 | `invalid_request`, `insufficient_funds`, `quote_expired`                |
| `AuthenticationError` | 401     | `invalid_signature`, `timestamp_out_of_range`, `key_revoked`            |
| `PermissionError`     | 403     | `insufficient_scope`, `ip_not_allowed`, `account_restricted`            |
| `NotFoundError`       | 404     | `not_found`                                                             |
| `ConflictError`       | 409     | `idempotency_conflict`, `idempotency_in_progress`, `quote_already_used` |
| `RateLimitError`      | 429     | `rate_limited`, `too_many_concurrent_requests`, `key_suspended`         |
| `ServiceError`        | 5xx     | `trading_halted`, `maintenance`, `service_unavailable`                  |
| `ApiError`            | other   | `method_not_allowed`, `payload_too_large`                               |

```python
import myntkaup

try:
    client.buy(asset="BTC", quote_amount="100.00")
except myntkaup.RateLimitError as error:
    print(f"Rate limited; retry in {error.retry_after} s", error.rate_limit)
except myntkaup.ApiError as error:
    print(error.status, error.code, error.message, error.request_id)
except myntkaup.ConnectionError as error:
    print("No answer:", error, error.__cause__)
```

An `ApiError` has `status`, `code`, `message`, `request_id` (quote it to support), `retry_after` (seconds, from `Retry-After`), `rate_limit` and `rate_limit_policy` (the raw `RateLimit` and `RateLimit-Policy` headers) and `headers`. A body without the error envelope, such as a load balancer's error page, has the code `unknown_error`.

A network failure or a timeout raises `myntkaup.ConnectionError`, which is not an `ApiError`. Both extend `MyntkaupError`. `myntkaup.PermissionError` and `myntkaup.ConnectionError` are not the built-in exceptions of the same names, so refer to them through the module (or import them under other names) rather than with `from myntkaup import *`. Invalid arguments, such as a float for an amount, raise `TypeError` or `ValueError` before anything is sent.

## Retries

Failed requests are retried at most `max_retries` times (2 by default), with exponential backoff and jitter (0.25-0.5 s, then 0.5-1 s, …, at most 8 s). When the server sends `Retry-After`, the client waits at least that long.

- GET requests are retried on 429, 502, 503 and 504, and on network errors and timeouts.
- POST requests are retried on the same, but only when they carry an `Idempotency-Key`: always for `trades.create`, `giftcards.create`, `withdrawals.create_bank` and `withdrawals.create_crypto`, and for `quotes.create` only if you pass `idempotency_key`.
- Never retried: 400, 401, 403, 404, 409, 422 and 500, and 429 `key_suspended`.
- A `Retry-After` of more than 60 seconds isn't waited for: the error is raised, with `retry_after` set.

Each attempt is signed again, with a new timestamp and nonce. Set `max_retries=0` to handle retries yourself.

Rate limits are per key and per account (see `client.limits.get()`), with at most 5 requests in flight per key. Don't fan out hundreds of concurrent requests; a key that is rate limited most of the time for 10 minutes is suspended for 15.

## Signing requests yourself

To call the API with another HTTP client, `sign_request` returns the four `MK-*` headers for one request:

```python
import json

import httpx
from myntkaup import sign_request

body = json.dumps({"asset": "BTC", "side": "buy", "quote_amount": "100.00"}).encode()
headers = sign_request(
    key_id="mk_live_…",
    private_key="mk_sk_…",
    method="POST",
    path_and_query="/v1/quotes",  # exactly as on the request line, query string included
    body=body,  # the exact bytes you send
)
httpx.post(
    "https://public-api.myntkaup.is/v1/quotes",
    content=body,
    headers={**headers, "Content-Type": "application/json"},
)
```

Sign every request afresh: a nonce must never repeat, and the timestamp must be within 30 seconds of the server clock (`server_time` in `client.status()`).

New response fields may be added within v1, so don't fail on keys you don't know.
