myntkaup 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- myntkaup-0.1.0/.gitignore +23 -0
- myntkaup-0.1.0/LICENSE +21 -0
- myntkaup-0.1.0/PKG-INFO +310 -0
- myntkaup-0.1.0/README.md +280 -0
- myntkaup-0.1.0/pyproject.toml +65 -0
- myntkaup-0.1.0/src/myntkaup/__init__.py +121 -0
- myntkaup-0.1.0/src/myntkaup/_async_client.py +528 -0
- myntkaup-0.1.0/src/myntkaup/_client.py +523 -0
- myntkaup-0.1.0/src/myntkaup/_core.py +472 -0
- myntkaup-0.1.0/src/myntkaup/_errors.py +115 -0
- myntkaup-0.1.0/src/myntkaup/_signing.py +99 -0
- myntkaup-0.1.0/src/myntkaup/_types.py +443 -0
- myntkaup-0.1.0/src/myntkaup/_version.py +1 -0
- myntkaup-0.1.0/src/myntkaup/py.typed +0 -0
- myntkaup-0.1.0/tests/__init__.py +0 -0
- myntkaup-0.1.0/tests/conftest.py +205 -0
- myntkaup-0.1.0/tests/fixtures.py +206 -0
- myntkaup-0.1.0/tests/test_errors.py +112 -0
- myntkaup-0.1.0/tests/test_helpers.py +137 -0
- myntkaup-0.1.0/tests/test_methods.py +593 -0
- myntkaup-0.1.0/tests/test_pagination.py +108 -0
- myntkaup-0.1.0/tests/test_retries.py +270 -0
- myntkaup-0.1.0/tests/test_signing.py +102 -0
- myntkaup-0.1.0/tests/test_types.py +180 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
coverage/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
.env
|
|
6
|
+
.env.*
|
|
7
|
+
!.env.example
|
|
8
|
+
*.key
|
|
9
|
+
*.pem
|
|
10
|
+
.DS_Store
|
|
11
|
+
.vscode/
|
|
12
|
+
.idea/
|
|
13
|
+
npm-debug.log*
|
|
14
|
+
pnpm-debug.log*
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.venv/
|
|
22
|
+
# pnpm pack:cli (docs/publishing.md)
|
|
23
|
+
.packs/
|
myntkaup-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Myntkaup ehf.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
myntkaup-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: myntkaup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Myntkaup public API: signed requests, retries, typed errors and pagination.
|
|
5
|
+
Project-URL: Homepage, https://developers.myntkaup.is
|
|
6
|
+
Project-URL: Source, https://github.com/myntkaup/myntkaup-public-api/tree/main/python
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: api,bitcoin,crypto,myntkaup,sdk
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: cryptography>=41
|
|
23
|
+
Requires-Dist: httpx<1,>=0.25
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: anyio>=4; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# myntkaup
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
- Python 3.10 or later. Typed (`py.typed`).
|
|
36
|
+
- Two dependencies: `httpx` and `cryptography`.
|
|
37
|
+
- `Myntkaup` (synchronous) and `AsyncMyntkaup` (asyncio), with the same methods.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip install myntkaup
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Configure
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from myntkaup import Myntkaup
|
|
51
|
+
|
|
52
|
+
client = Myntkaup() # reads MYNTKAUP_API_KEY_ID and MYNTKAUP_API_PRIVATE_KEY
|
|
53
|
+
# or
|
|
54
|
+
client = Myntkaup(key_id="mk_live_…", private_key="mk_sk_…")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Option | Default |
|
|
58
|
+
| ------------- | ----------------------------------------------------------------------------------------------- |
|
|
59
|
+
| `key_id` | `MYNTKAUP_API_KEY_ID` |
|
|
60
|
+
| `private_key` | `MYNTKAUP_API_PRIVATE_KEY` |
|
|
61
|
+
| `base_url` | `https://public-api.myntkaup.is`; `https://public-api.staging.myntkaup.is` for a `mk_test_` key |
|
|
62
|
+
| `timeout` | `30.0` seconds, per attempt |
|
|
63
|
+
| `max_retries` | `2` |
|
|
64
|
+
| `transport` | an `httpx` transport, e.g. `httpx.MockTransport` in tests |
|
|
65
|
+
|
|
66
|
+
The client holds a connection pool: use it as a context manager (`with Myntkaup() as client:`) or call `client.close()` when done.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
## Examples
|
|
71
|
+
|
|
72
|
+
### Status, account and market data
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
status = client.status() # unsigned; status["server_time"] is the server clock
|
|
76
|
+
me = client.me() # the account and this key
|
|
77
|
+
print(me["account"]["tier"], me["key"]["scopes"])
|
|
78
|
+
|
|
79
|
+
assets = client.assets.list()["data"]
|
|
80
|
+
prices = client.prices.list() # every asset at your tier, in USDC
|
|
81
|
+
btc = client.prices.get("BTC") # {"asset", "quote_currency", "buy", "sell", "updated_at"}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Balances and limits
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
balances = client.balances.list()
|
|
88
|
+
for balance in balances["data"]:
|
|
89
|
+
print(balance["asset"], balance["available"], balance["held"])
|
|
90
|
+
|
|
91
|
+
limits = client.limits.get()
|
|
92
|
+
print(limits["trade"]["daily_volume_used_usdc"], limits["rate_limits"]["classes"]["quote"])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Trading
|
|
96
|
+
|
|
97
|
+
The quick way: `buy` and `sell` create a quote and accept it.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
trade = client.buy(asset="BTC", quote_amount="100.00") # spend 100 USDC
|
|
101
|
+
sold = client.sell(asset="BTC", base_amount="0.001") # sell 0.001 BTC
|
|
102
|
+
|
|
103
|
+
if trade["status"] == "filled":
|
|
104
|
+
print(f"Bought {trade['base_amount']} BTC at {trade['price']}")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
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"])`.
|
|
108
|
+
|
|
109
|
+
Step by step:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
quote = client.quotes.create(asset="BTC", side="buy", quote_amount="100.00")
|
|
113
|
+
# or asset="BTC", side="sell", base_amount="0.001": exactly one of the two
|
|
114
|
+
print(quote["price"], quote["base_amount"], quote["expires_at"]) # valid for about 10 seconds
|
|
115
|
+
|
|
116
|
+
trade = client.trades.create(quote_id=quote["id"]) # sends an Idempotency-Key
|
|
117
|
+
quote = client.quotes.get(quote["id"]) # status: open, accepted, expired, filled or failed
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`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:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
client.trades.create(quote_id=quote["id"], idempotency_key=f"order-{order_id}")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### History
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
page = client.trades.list(asset="BTC", side="buy", limit=100)
|
|
130
|
+
print(page["data"], page["next_cursor"]) # pass next_cursor as cursor for the next page
|
|
131
|
+
|
|
132
|
+
trade = client.trades.get("tr_01JB8X2Q4M7Z3V9K5T1N6R0P2W")
|
|
133
|
+
|
|
134
|
+
# list_all follows next_cursor for you, one page at a time.
|
|
135
|
+
from datetime import datetime, timezone
|
|
136
|
+
|
|
137
|
+
for entry in client.transactions.list_all(
|
|
138
|
+
from_="2026-01-01T00:00:00Z", # from_ is sent as from
|
|
139
|
+
to=datetime.now(timezone.utc), # aware datetimes are sent as ISO 8601 UTC
|
|
140
|
+
type="trade",
|
|
141
|
+
lang="is", # labels in Icelandic; en is the default
|
|
142
|
+
):
|
|
143
|
+
print(entry["created_at"], entry["label"], entry["changes"])
|
|
144
|
+
|
|
145
|
+
entry = client.transactions.get("tx_01JB8X2Q4M7Z3V9K5T1N6R0P2W", lang="en")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`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).
|
|
149
|
+
|
|
150
|
+
### Gift cards
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
card = client.giftcards.create(asset="BTC", amount="0.0005", recipient_name="Anna")
|
|
154
|
+
print(card["code"]) # treat it like cash
|
|
155
|
+
|
|
156
|
+
pdf = client.giftcards.pdf(card["id"]) # bytes
|
|
157
|
+
with open(f"{card['id']}.pdf", "wb") as file:
|
|
158
|
+
file.write(pdf)
|
|
159
|
+
|
|
160
|
+
page = client.giftcards.list(status="unredeemed")
|
|
161
|
+
one = client.giftcards.get(card["id"])
|
|
162
|
+
for giftcard in client.giftcards.list_all():
|
|
163
|
+
print(giftcard["id"], giftcard["status"])
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Creating gift cards needs the `giftcards:create` scope. Like trades, `giftcards.create` sends an `Idempotency-Key` unless you give one.
|
|
167
|
+
|
|
168
|
+
### Withdrawals
|
|
169
|
+
|
|
170
|
+
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-…).
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
accounts = client.bank_accounts.list()["data"] # most recently used first
|
|
174
|
+
payout = client.withdrawals.create_bank(
|
|
175
|
+
amount="250.00", # USDC, taken from the balance, fee included
|
|
176
|
+
bank_account=accounts[0]["account_number"], # or a new one, e.g. "0133-26-012345"
|
|
177
|
+
)
|
|
178
|
+
print(payout["status"]) # pending: paid out on a business day
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
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):
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
withdrawal = client.withdrawals.create_crypto(
|
|
185
|
+
asset="BTC",
|
|
186
|
+
amount="0.01", # fee included
|
|
187
|
+
address="bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
|
188
|
+
travel_rule={"address_owner": "self", "wallet_type": "hosted", "vasp": "kraken"}, # your Kraken account
|
|
189
|
+
)
|
|
190
|
+
print(withdrawal["status"], withdrawal["confirm_by"]) # awaiting_confirmation, until when the link works
|
|
191
|
+
|
|
192
|
+
# After the link is opened: pending, then completed with tx_id. Unconfirmed: expired.
|
|
193
|
+
latest = client.withdrawals.get(withdrawal["id"])
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`travel_rule` says who receives the funds. To someone else's wallet, name them:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
travel_rule = {
|
|
200
|
+
"address_owner": "other",
|
|
201
|
+
"wallet_type": "self_hosted", # or "hosted", with vasp (vasp_name when vasp is "other")
|
|
202
|
+
"beneficiary": {"type": "individual", "full_name": "Anna Þórsdóttir"},
|
|
203
|
+
# a company: {"type": "company", "company_name": "Dæmi ehf.", "registration_number": "5501012340"}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Give `network` for an asset on several networks (USDC: `ethereum`, the default, or `solana`), and `memo` for XRP (destination tag) and HBAR.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
page = client.withdrawals.list(type="crypto", status="pending")
|
|
211
|
+
for entry in client.withdrawals.list_all(from_="2026-01-01T00:00:00Z"):
|
|
212
|
+
print(entry["id"], entry["type"], entry["status"], entry["amount"])
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
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`.
|
|
216
|
+
|
|
217
|
+
### Async
|
|
218
|
+
|
|
219
|
+
`AsyncMyntkaup` has the same methods as coroutines; `list_all` returns an async iterator.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
import asyncio
|
|
223
|
+
|
|
224
|
+
from myntkaup import AsyncMyntkaup
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def main() -> None:
|
|
228
|
+
async with AsyncMyntkaup() as client:
|
|
229
|
+
balances, prices = await asyncio.gather(client.balances.list(), client.prices.list())
|
|
230
|
+
trade = await client.buy(asset="BTC", quote_amount="25.00")
|
|
231
|
+
async for entry in client.transactions.list_all(type="trade"):
|
|
232
|
+
print(entry["label"])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
asyncio.run(main())
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Errors
|
|
239
|
+
|
|
240
|
+
Every non-2xx answer raises an `ApiError` subclass. Match on `code`, never on `message`.
|
|
241
|
+
|
|
242
|
+
| Exception | Status | Examples of `code` |
|
|
243
|
+
| --------------------- | ------- | ----------------------------------------------------------------------- |
|
|
244
|
+
| `ValidationError` | 400/422 | `invalid_request`, `insufficient_funds`, `quote_expired` |
|
|
245
|
+
| `AuthenticationError` | 401 | `invalid_signature`, `timestamp_out_of_range`, `key_revoked` |
|
|
246
|
+
| `PermissionError` | 403 | `insufficient_scope`, `ip_not_allowed`, `account_restricted` |
|
|
247
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
248
|
+
| `ConflictError` | 409 | `idempotency_conflict`, `idempotency_in_progress`, `quote_already_used` |
|
|
249
|
+
| `RateLimitError` | 429 | `rate_limited`, `too_many_concurrent_requests`, `key_suspended` |
|
|
250
|
+
| `ServiceError` | 5xx | `trading_halted`, `maintenance`, `service_unavailable` |
|
|
251
|
+
| `ApiError` | other | `method_not_allowed`, `payload_too_large` |
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
import myntkaup
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
client.buy(asset="BTC", quote_amount="100.00")
|
|
258
|
+
except myntkaup.RateLimitError as error:
|
|
259
|
+
print(f"Rate limited; retry in {error.retry_after} s", error.rate_limit)
|
|
260
|
+
except myntkaup.ApiError as error:
|
|
261
|
+
print(error.status, error.code, error.message, error.request_id)
|
|
262
|
+
except myntkaup.ConnectionError as error:
|
|
263
|
+
print("No answer:", error, error.__cause__)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
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`.
|
|
267
|
+
|
|
268
|
+
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.
|
|
269
|
+
|
|
270
|
+
## Retries
|
|
271
|
+
|
|
272
|
+
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.
|
|
273
|
+
|
|
274
|
+
- GET requests are retried on 429, 502, 503 and 504, and on network errors and timeouts.
|
|
275
|
+
- 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`.
|
|
276
|
+
- Never retried: 400, 401, 403, 404, 409, 422 and 500, and 429 `key_suspended`.
|
|
277
|
+
- A `Retry-After` of more than 60 seconds isn't waited for: the error is raised, with `retry_after` set.
|
|
278
|
+
|
|
279
|
+
Each attempt is signed again, with a new timestamp and nonce. Set `max_retries=0` to handle retries yourself.
|
|
280
|
+
|
|
281
|
+
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.
|
|
282
|
+
|
|
283
|
+
## Signing requests yourself
|
|
284
|
+
|
|
285
|
+
To call the API with another HTTP client, `sign_request` returns the four `MK-*` headers for one request:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
import json
|
|
289
|
+
|
|
290
|
+
import httpx
|
|
291
|
+
from myntkaup import sign_request
|
|
292
|
+
|
|
293
|
+
body = json.dumps({"asset": "BTC", "side": "buy", "quote_amount": "100.00"}).encode()
|
|
294
|
+
headers = sign_request(
|
|
295
|
+
key_id="mk_live_…",
|
|
296
|
+
private_key="mk_sk_…",
|
|
297
|
+
method="POST",
|
|
298
|
+
path_and_query="/v1/quotes", # exactly as on the request line, query string included
|
|
299
|
+
body=body, # the exact bytes you send
|
|
300
|
+
)
|
|
301
|
+
httpx.post(
|
|
302
|
+
"https://public-api.myntkaup.is/v1/quotes",
|
|
303
|
+
content=body,
|
|
304
|
+
headers={**headers, "Content-Type": "application/json"},
|
|
305
|
+
)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
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()`).
|
|
309
|
+
|
|
310
|
+
New response fields may be added within v1, so don't fail on keys you don't know.
|
myntkaup-0.1.0/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# myntkaup
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
- Python 3.10 or later. Typed (`py.typed`).
|
|
6
|
+
- Two dependencies: `httpx` and `cryptography`.
|
|
7
|
+
- `Myntkaup` (synchronous) and `AsyncMyntkaup` (asyncio), with the same methods.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install myntkaup
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configure
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from myntkaup import Myntkaup
|
|
21
|
+
|
|
22
|
+
client = Myntkaup() # reads MYNTKAUP_API_KEY_ID and MYNTKAUP_API_PRIVATE_KEY
|
|
23
|
+
# or
|
|
24
|
+
client = Myntkaup(key_id="mk_live_…", private_key="mk_sk_…")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Option | Default |
|
|
28
|
+
| ------------- | ----------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `key_id` | `MYNTKAUP_API_KEY_ID` |
|
|
30
|
+
| `private_key` | `MYNTKAUP_API_PRIVATE_KEY` |
|
|
31
|
+
| `base_url` | `https://public-api.myntkaup.is`; `https://public-api.staging.myntkaup.is` for a `mk_test_` key |
|
|
32
|
+
| `timeout` | `30.0` seconds, per attempt |
|
|
33
|
+
| `max_retries` | `2` |
|
|
34
|
+
| `transport` | an `httpx` transport, e.g. `httpx.MockTransport` in tests |
|
|
35
|
+
|
|
36
|
+
The client holds a connection pool: use it as a context manager (`with Myntkaup() as client:`) or call `client.close()` when done.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
## Examples
|
|
41
|
+
|
|
42
|
+
### Status, account and market data
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
status = client.status() # unsigned; status["server_time"] is the server clock
|
|
46
|
+
me = client.me() # the account and this key
|
|
47
|
+
print(me["account"]["tier"], me["key"]["scopes"])
|
|
48
|
+
|
|
49
|
+
assets = client.assets.list()["data"]
|
|
50
|
+
prices = client.prices.list() # every asset at your tier, in USDC
|
|
51
|
+
btc = client.prices.get("BTC") # {"asset", "quote_currency", "buy", "sell", "updated_at"}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Balances and limits
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
balances = client.balances.list()
|
|
58
|
+
for balance in balances["data"]:
|
|
59
|
+
print(balance["asset"], balance["available"], balance["held"])
|
|
60
|
+
|
|
61
|
+
limits = client.limits.get()
|
|
62
|
+
print(limits["trade"]["daily_volume_used_usdc"], limits["rate_limits"]["classes"]["quote"])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Trading
|
|
66
|
+
|
|
67
|
+
The quick way: `buy` and `sell` create a quote and accept it.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
trade = client.buy(asset="BTC", quote_amount="100.00") # spend 100 USDC
|
|
71
|
+
sold = client.sell(asset="BTC", base_amount="0.001") # sell 0.001 BTC
|
|
72
|
+
|
|
73
|
+
if trade["status"] == "filled":
|
|
74
|
+
print(f"Bought {trade['base_amount']} BTC at {trade['price']}")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
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"])`.
|
|
78
|
+
|
|
79
|
+
Step by step:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
quote = client.quotes.create(asset="BTC", side="buy", quote_amount="100.00")
|
|
83
|
+
# or asset="BTC", side="sell", base_amount="0.001": exactly one of the two
|
|
84
|
+
print(quote["price"], quote["base_amount"], quote["expires_at"]) # valid for about 10 seconds
|
|
85
|
+
|
|
86
|
+
trade = client.trades.create(quote_id=quote["id"]) # sends an Idempotency-Key
|
|
87
|
+
quote = client.quotes.get(quote["id"]) # status: open, accepted, expired, filled or failed
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`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:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
client.trades.create(quote_id=quote["id"], idempotency_key=f"order-{order_id}")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### History
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
page = client.trades.list(asset="BTC", side="buy", limit=100)
|
|
100
|
+
print(page["data"], page["next_cursor"]) # pass next_cursor as cursor for the next page
|
|
101
|
+
|
|
102
|
+
trade = client.trades.get("tr_01JB8X2Q4M7Z3V9K5T1N6R0P2W")
|
|
103
|
+
|
|
104
|
+
# list_all follows next_cursor for you, one page at a time.
|
|
105
|
+
from datetime import datetime, timezone
|
|
106
|
+
|
|
107
|
+
for entry in client.transactions.list_all(
|
|
108
|
+
from_="2026-01-01T00:00:00Z", # from_ is sent as from
|
|
109
|
+
to=datetime.now(timezone.utc), # aware datetimes are sent as ISO 8601 UTC
|
|
110
|
+
type="trade",
|
|
111
|
+
lang="is", # labels in Icelandic; en is the default
|
|
112
|
+
):
|
|
113
|
+
print(entry["created_at"], entry["label"], entry["changes"])
|
|
114
|
+
|
|
115
|
+
entry = client.transactions.get("tx_01JB8X2Q4M7Z3V9K5T1N6R0P2W", lang="en")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`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).
|
|
119
|
+
|
|
120
|
+
### Gift cards
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
card = client.giftcards.create(asset="BTC", amount="0.0005", recipient_name="Anna")
|
|
124
|
+
print(card["code"]) # treat it like cash
|
|
125
|
+
|
|
126
|
+
pdf = client.giftcards.pdf(card["id"]) # bytes
|
|
127
|
+
with open(f"{card['id']}.pdf", "wb") as file:
|
|
128
|
+
file.write(pdf)
|
|
129
|
+
|
|
130
|
+
page = client.giftcards.list(status="unredeemed")
|
|
131
|
+
one = client.giftcards.get(card["id"])
|
|
132
|
+
for giftcard in client.giftcards.list_all():
|
|
133
|
+
print(giftcard["id"], giftcard["status"])
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Creating gift cards needs the `giftcards:create` scope. Like trades, `giftcards.create` sends an `Idempotency-Key` unless you give one.
|
|
137
|
+
|
|
138
|
+
### Withdrawals
|
|
139
|
+
|
|
140
|
+
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-…).
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
accounts = client.bank_accounts.list()["data"] # most recently used first
|
|
144
|
+
payout = client.withdrawals.create_bank(
|
|
145
|
+
amount="250.00", # USDC, taken from the balance, fee included
|
|
146
|
+
bank_account=accounts[0]["account_number"], # or a new one, e.g. "0133-26-012345"
|
|
147
|
+
)
|
|
148
|
+
print(payout["status"]) # pending: paid out on a business day
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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):
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
withdrawal = client.withdrawals.create_crypto(
|
|
155
|
+
asset="BTC",
|
|
156
|
+
amount="0.01", # fee included
|
|
157
|
+
address="bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
|
158
|
+
travel_rule={"address_owner": "self", "wallet_type": "hosted", "vasp": "kraken"}, # your Kraken account
|
|
159
|
+
)
|
|
160
|
+
print(withdrawal["status"], withdrawal["confirm_by"]) # awaiting_confirmation, until when the link works
|
|
161
|
+
|
|
162
|
+
# After the link is opened: pending, then completed with tx_id. Unconfirmed: expired.
|
|
163
|
+
latest = client.withdrawals.get(withdrawal["id"])
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`travel_rule` says who receives the funds. To someone else's wallet, name them:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
travel_rule = {
|
|
170
|
+
"address_owner": "other",
|
|
171
|
+
"wallet_type": "self_hosted", # or "hosted", with vasp (vasp_name when vasp is "other")
|
|
172
|
+
"beneficiary": {"type": "individual", "full_name": "Anna Þórsdóttir"},
|
|
173
|
+
# a company: {"type": "company", "company_name": "Dæmi ehf.", "registration_number": "5501012340"}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Give `network` for an asset on several networks (USDC: `ethereum`, the default, or `solana`), and `memo` for XRP (destination tag) and HBAR.
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
page = client.withdrawals.list(type="crypto", status="pending")
|
|
181
|
+
for entry in client.withdrawals.list_all(from_="2026-01-01T00:00:00Z"):
|
|
182
|
+
print(entry["id"], entry["type"], entry["status"], entry["amount"])
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
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`.
|
|
186
|
+
|
|
187
|
+
### Async
|
|
188
|
+
|
|
189
|
+
`AsyncMyntkaup` has the same methods as coroutines; `list_all` returns an async iterator.
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
import asyncio
|
|
193
|
+
|
|
194
|
+
from myntkaup import AsyncMyntkaup
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def main() -> None:
|
|
198
|
+
async with AsyncMyntkaup() as client:
|
|
199
|
+
balances, prices = await asyncio.gather(client.balances.list(), client.prices.list())
|
|
200
|
+
trade = await client.buy(asset="BTC", quote_amount="25.00")
|
|
201
|
+
async for entry in client.transactions.list_all(type="trade"):
|
|
202
|
+
print(entry["label"])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
asyncio.run(main())
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Errors
|
|
209
|
+
|
|
210
|
+
Every non-2xx answer raises an `ApiError` subclass. Match on `code`, never on `message`.
|
|
211
|
+
|
|
212
|
+
| Exception | Status | Examples of `code` |
|
|
213
|
+
| --------------------- | ------- | ----------------------------------------------------------------------- |
|
|
214
|
+
| `ValidationError` | 400/422 | `invalid_request`, `insufficient_funds`, `quote_expired` |
|
|
215
|
+
| `AuthenticationError` | 401 | `invalid_signature`, `timestamp_out_of_range`, `key_revoked` |
|
|
216
|
+
| `PermissionError` | 403 | `insufficient_scope`, `ip_not_allowed`, `account_restricted` |
|
|
217
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
218
|
+
| `ConflictError` | 409 | `idempotency_conflict`, `idempotency_in_progress`, `quote_already_used` |
|
|
219
|
+
| `RateLimitError` | 429 | `rate_limited`, `too_many_concurrent_requests`, `key_suspended` |
|
|
220
|
+
| `ServiceError` | 5xx | `trading_halted`, `maintenance`, `service_unavailable` |
|
|
221
|
+
| `ApiError` | other | `method_not_allowed`, `payload_too_large` |
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
import myntkaup
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
client.buy(asset="BTC", quote_amount="100.00")
|
|
228
|
+
except myntkaup.RateLimitError as error:
|
|
229
|
+
print(f"Rate limited; retry in {error.retry_after} s", error.rate_limit)
|
|
230
|
+
except myntkaup.ApiError as error:
|
|
231
|
+
print(error.status, error.code, error.message, error.request_id)
|
|
232
|
+
except myntkaup.ConnectionError as error:
|
|
233
|
+
print("No answer:", error, error.__cause__)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
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`.
|
|
237
|
+
|
|
238
|
+
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.
|
|
239
|
+
|
|
240
|
+
## Retries
|
|
241
|
+
|
|
242
|
+
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.
|
|
243
|
+
|
|
244
|
+
- GET requests are retried on 429, 502, 503 and 504, and on network errors and timeouts.
|
|
245
|
+
- 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`.
|
|
246
|
+
- Never retried: 400, 401, 403, 404, 409, 422 and 500, and 429 `key_suspended`.
|
|
247
|
+
- A `Retry-After` of more than 60 seconds isn't waited for: the error is raised, with `retry_after` set.
|
|
248
|
+
|
|
249
|
+
Each attempt is signed again, with a new timestamp and nonce. Set `max_retries=0` to handle retries yourself.
|
|
250
|
+
|
|
251
|
+
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.
|
|
252
|
+
|
|
253
|
+
## Signing requests yourself
|
|
254
|
+
|
|
255
|
+
To call the API with another HTTP client, `sign_request` returns the four `MK-*` headers for one request:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
import json
|
|
259
|
+
|
|
260
|
+
import httpx
|
|
261
|
+
from myntkaup import sign_request
|
|
262
|
+
|
|
263
|
+
body = json.dumps({"asset": "BTC", "side": "buy", "quote_amount": "100.00"}).encode()
|
|
264
|
+
headers = sign_request(
|
|
265
|
+
key_id="mk_live_…",
|
|
266
|
+
private_key="mk_sk_…",
|
|
267
|
+
method="POST",
|
|
268
|
+
path_and_query="/v1/quotes", # exactly as on the request line, query string included
|
|
269
|
+
body=body, # the exact bytes you send
|
|
270
|
+
)
|
|
271
|
+
httpx.post(
|
|
272
|
+
"https://public-api.myntkaup.is/v1/quotes",
|
|
273
|
+
content=body,
|
|
274
|
+
headers={**headers, "Content-Type": "application/json"},
|
|
275
|
+
)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
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()`).
|
|
279
|
+
|
|
280
|
+
New response fields may be added within v1, so don't fail on keys you don't know.
|