Get started
Authentication
Every request except GET /v1/status is signed with your key's Ed25519 private key. Myntkaup stores only the public key, so no secret ever travels with a request.
How it works
When you create a key, your browser makes an Ed25519 key pair and sends Myntkaup only the public half. To call the API, your program signs a short description of each request (the canonical string) with the private key and sends the signature in a header. Myntkaup checks it against the public key.
A signature is valid for one request: it covers the method, path, query and body, a timestamp good for 30 seconds and a random nonce that can't be used twice. A signature that leaks from a log can't be replayed or reused for anything else, and a leak of our key store would give no usable credential.
The SDKs do all of this for you. Read on if you sign requests yourself.
Headers
Every signed request carries four headers. Requests with a body also need Content-Type: application/json.
| Header | Value |
|---|---|
MK-Key-Id | The key id: mk_live_ (production) or mk_test_ (staging), then 20 characters a-z0-9. |
MK-Timestamp | Milliseconds since the Unix epoch, digits only, within 30 seconds of the server clock. |
MK-Nonce | 32 lowercase hex characters (16 random bytes), never used before by this key. |
MK-Signature | Standard base64 (with padding, 88 characters) of the 64-byte Ed25519 signature of the canonical string. |
The canonical string
Seven lines joined with a single line feed (\n), with no newline at the end:
myntkaup-public-v1
<MK-Key-Id>
<MK-Timestamp>
<MK-Nonce>
<METHOD>
<path and query>
<SHA-256 of the body, lowercase hex>- METHOD is upper case:
GETorPOST. - Path and query is exactly what's in the request line, without the host:
/v1/transactions?limit=10&asset=BTC. Keep the parameters in the order you send them, and sign what your HTTP library actually sends if it re-encodes the query. - The body hash is over the exact bytes you send. Serialize the JSON once, sign that string, and send the same string. A request without a body hashes the empty string:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. - The signature is over the canonical string encoded as UTF-8.
Keys
- The key id names the key. It isn't secret.
- The private key is
mk_sk_followed by base64url (no padding) of the 32-byte Ed25519 seed. Libraries that load PKCS #8 keys need the seed behind the prefix302e020100300506032b657004220420(hex), as the samples below do. mk_live_keys work only onhttps://public-api.myntkaup.is, andmk_test_keys only on staging. A key sent to the wrong environment gets401 invalid_signature.
Timestamps and nonces
MK-Timestampmust be within 30 seconds of the server clock, or the request gets401 timestamp_out_of_range. Keep your clock synced (NTP).GET /v1/statusreturns the server clock asserver_timewithout a signature.MK-Nonceis 16 bytes from a cryptographically secure random generator, as hex. Reusing one with the same key gets401 nonce_reused. Nonces are random rather than increasing, so you can send requests in parallel.- To retry a request, sign it again: a new timestamp and a new nonce. What makes a retried
POSTsafe is its Idempotency-Key, not the signature.
A worked example
These values are the POST /v1/quotes case of the shared test vectors that the API and both SDKs are tested against. Ed25519 signatures are deterministic, so your code must produce exactly this signature from these inputs. All four cases are in test-vectors.json.
- Private key
mk_sk_nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2ARFC 8032 test key 1. Public; never use it for real.- Key id
mk_test_0123456789abcdefghij- Timestamp
1767225600123- Nonce
00112233445566778899aabbccddeeff- Request
POST /v1/quotes- Body
{"asset":"BTC","side":"buy","quote_amount":"100.00"}- SHA-256 of the body
82c3b1d587da21ee0fd5d8e59279cdea07a26416867024da753a066318f74e5b
myntkaup-public-v1
mk_test_0123456789abcdefghij
1767225600123
00112233445566778899aabbccddeeff
POST
/v1/quotes
82c3b1d587da21ee0fd5d8e59279cdea07a26416867024da753a066318f74e5bPOST /v1/quotes HTTP/1.1
Host: public-api.myntkaup.is
Content-Type: application/json
MK-Key-Id: mk_test_0123456789abcdefghij
MK-Timestamp: 1767225600123
MK-Nonce: 00112233445566778899aabbccddeeff
MK-Signature: g0UAy9BdJ+AxfeYqawRhlNpdrgx1GSoWMZUotDl4efiOFsA7UAkumblZ8TCJ3qF5VTEnqJDThZsZGtjCegfKDQ==
{"asset":"BTC","side":"buy","quote_amount":"100.00"}Signing code
Node.js
Uses only node:crypto. Pass the exact body string you send.
// Signs a Myntkaup API request with nothing but node:crypto (Node 18 or later).
// test/samples.test.ts checks it against packages/signing/test-vectors.json.
import { createHash, createPrivateKey, randomBytes, sign } from "node:crypto";
// Turns the 32-byte seed in mk_sk_… into a PKCS #8 key Node can load.
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
/**
* The four MK-* headers for one request. `body` must be the exact string or
* bytes you send; leave it empty for a GET.
*/
export function signHeaders({
keyId,
privateKey,
method,
pathAndQuery,
body = "",
timestamp = Date.now(),
nonce = randomBytes(16).toString("hex"),
}) {
const seed = Buffer.from(privateKey.replace(/^mk_sk_/, ""), "base64url");
const key = createPrivateKey({
key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),
format: "der",
type: "pkcs8",
});
const canonical = [
"myntkaup-public-v1",
keyId,
String(timestamp),
nonce,
method.toUpperCase(),
pathAndQuery,
createHash("sha256").update(body).digest("hex"),
].join("\n");
return {
"MK-Key-Id": keyId,
"MK-Timestamp": String(timestamp),
"MK-Nonce": nonce,
"MK-Signature": sign(null, Buffer.from(canonical, "utf8"), key).toString("base64"),
};
}import { signHeaders } from "./sign-headers.mjs";
const body = JSON.stringify({ asset: "BTC", side: "buy", quote_amount: "100.00" });
const headers = signHeaders({
keyId: process.env.MYNTKAUP_API_KEY_ID,
privateKey: process.env.MYNTKAUP_API_PRIVATE_KEY,
method: "POST",
pathAndQuery: "/v1/quotes",
body,
});
const response = await fetch("https://public-api.myntkaup.is/v1/quotes", {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body,
});Python
Uses the cryptography package.
"""Signs a Myntkaup API request with the `cryptography` package (Python 3.9+).
test/samples.test.ts checks it against packages/signing/test-vectors.json.
"""
import base64
import hashlib
import secrets
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def sign_headers(key_id, private_key, method, path_and_query, body=b"", timestamp=None, nonce=None):
"""Returns the four MK-* headers. `body` must be the exact bytes you send."""
if isinstance(body, str):
body = body.encode("utf-8")
seed = base64.urlsafe_b64decode(private_key.removeprefix("mk_sk_") + "=")
key = Ed25519PrivateKey.from_private_bytes(seed)
timestamp = str(timestamp if timestamp is not None else int(time.time() * 1000))
nonce = nonce or secrets.token_hex(16)
canonical = "\n".join(
[
"myntkaup-public-v1",
key_id,
timestamp,
nonce,
method.upper(),
path_and_query,
hashlib.sha256(body).hexdigest(),
]
)
signature = base64.b64encode(key.sign(canonical.encode("utf-8"))).decode("ascii")
return {
"MK-Key-Id": key_id,
"MK-Timestamp": timestamp,
"MK-Nonce": nonce,
"MK-Signature": signature,
}import json
import os
import httpx
from sign_headers import sign_headers
body = json.dumps({"asset": "BTC", "side": "buy", "quote_amount": "100.00"}, separators=(",", ":"))
headers = sign_headers(
os.environ["MYNTKAUP_API_KEY_ID"],
os.environ["MYNTKAUP_API_PRIVATE_KEY"],
"POST",
"/v1/quotes",
body,
)
response = httpx.post(
"https://public-api.myntkaup.is/v1/quotes",
content=body, # the signed bytes; json= would serialize them again
headers={**headers, "Content-Type": "application/json"},
)Shell
sign-headers.sh prints the four headers using OpenSSL 3 and coreutils. It reads the key from MYNTKAUP_API_KEY_ID and MYNTKAUP_API_PRIVATE_KEY, the names in the .env file you download when you create a key. The reference's curl samples use it.
chmod +x sign-headers.sh
set -a; . ./.env; set +a # MYNTKAUP_API_KEY_ID and MYNTKAUP_API_PRIVATE_KEY
curl https://public-api.myntkaup.is/v1/me $(./sign-headers.sh GET /v1/me | sed 's/^/-H /')
body='{"asset":"BTC","side":"buy","quote_amount":"100.00"}'
curl https://public-api.myntkaup.is/v1/quotes \
-H "Content-Type: application/json" \
$(./sign-headers.sh POST /v1/quotes "$body" | sed 's/^/-H /') \
--data "$body"macOS ships LibreSSL as openssl. Install OpenSSL 3 (brew install openssl@3) and point the script at it with OPENSSL="$(brew --prefix openssl@3)/bin/openssl".
#!/usr/bin/env bash
# Prints the four MK-* headers for a request, with nothing but OpenSSL 3 and
# coreutils: the shell version of packages/signing (contract section 1).
#
# The key is read from MYNTKAUP_API_KEY_ID and MYNTKAUP_API_PRIVATE_KEY, the
# names in the .env file the webapp gives you (MK_KEY_ID / MK_PRIVATE_KEY
# still work):
#
# export MYNTKAUP_API_KEY_ID=mk_test_… MYNTKAUP_API_PRIVATE_KEY=mk_sk_…
# scripts/sign-headers.sh GET /v1/me
# curl https://public-api.staging.myntkaup.is/v1/me $(scripts/sign-headers.sh GET /v1/me | sed 's/^/-H /') # values have no spaces
#
# Sign the exact body bytes you send. MK_TIMESTAMP and MK_NONCE may be set to
# reproduce the test vectors.
set -euo pipefail
OPENSSL="${OPENSSL:-openssl}"
method="$1" path="$2" body="${3:-}"
key_id="${MYNTKAUP_API_KEY_ID:-${MK_KEY_ID:?set MYNTKAUP_API_KEY_ID}}"
private_key="${MYNTKAUP_API_PRIVATE_KEY:-${MK_PRIVATE_KEY:?set MYNTKAUP_API_PRIVATE_KEY}}"
timestamp="${MK_TIMESTAMP:-$(( $(date +%s) * 1000 ))}"
nonce="${MK_NONCE:-$("$OPENSSL" rand -hex 16)}"
key="$(mktemp)"; msg="$(mktemp)"; trap 'rm -f "$key" "$msg"' EXIT
{ printf '302e020100300506032b657004220420' | xxd -r -p
printf '%s=' "${private_key#mk_sk_}" | tr '_-' '/+' | base64 -d; } > "$key"
body_hash="$(printf '%s' "$body" | "$OPENSSL" dgst -sha256 -r | cut -d' ' -f1)"
printf 'myntkaup-public-v1\n%s\n%s\n%s\n%s\n%s\n%s' "$key_id" "$timestamp" "$nonce" "$method" "$path" "$body_hash" > "$msg"
signature="$("$OPENSSL" pkeyutl -sign -inkey "$key" -keyform DER -rawin -in "$msg" | base64 | tr -d '\n')"
echo "MK-Key-Id:$key_id"
echo "MK-Timestamp:$timestamp"
echo "MK-Nonce:$nonce"
echo "MK-Signature:$signature"When authentication fails
| Status | Code | Usual cause |
|---|---|---|
| 401 | invalid_signature | Wrong key id or environment, a body that differs from the signed one, or a canonical string with a trailing newline. |
| 401 | timestamp_out_of_range | The clock is more than 30 seconds off, or a signed request was sent late. |
| 401 | nonce_reused | A request was re-sent without being signed again. |
| 401 | key_expired | The key passed its expiry date. |
| 401 | key_revoked | The key was revoked in the webapp. |
| 403 | ip_not_allowed | The key has an IP allowlist and the request came from another address. |
| 403 | insufficient_scope | The key lacks the scope the endpoint needs. |
Every 401 and every ip_not_allowed counts against the calling IP address: 20 failures in 5 minutes block it for 15 minutes, with 429 rate_limited. Fix the signing code before retrying in a loop.
Keeping keys safe
- Grant the least. Keys are read-only by default. Give
tradeandgiftcards:createonly to programs that need them, and use a separate key for each program. - Add an IP allowlist. It's optional for every key, and recommended for any key that can trade or create gift cards: a leaked key is then of no use from anywhere else.
- Store private keys as secrets: in a secret manager or a
.envfile outside version control. Never put them in a browser, a mobile app or a shared chat, and never paste them into AI tools. - Rotate. A key can last 30 days, 90 days, a year, or have no expiry; we email 7 days before an expiry date. Rotate keys without expiry yourself too: create the new key, deploy it, then revoke the old one.
- Revoke at once if a key may have leaked: Settings → API → Revoke, or Revoke all. Revoking needs no electronic ID and takes effect immediately.
- Watch your keys. The list in Settings shows when and from where each key was last used, and the account owner is emailed about every new, revoked or suspended key.