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.

HeaderValue
MK-Key-IdThe key id: mk_live_ (production) or mk_test_ (staging), then 20 characters a-z0-9.
MK-TimestampMilliseconds since the Unix epoch, digits only, within 30 seconds of the server clock.
MK-Nonce32 lowercase hex characters (16 random bytes), never used before by this key.
MK-SignatureStandard 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:

Canonical string
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: GET or POST.
  • 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 prefix 302e020100300506032b657004220420 (hex), as the samples below do.
  • mk_live_ keys work only on https://public-api.myntkaup.is, and mk_test_ keys only on staging. A key sent to the wrong environment gets 401 invalid_signature.

Timestamps and nonces

  • MK-Timestamp must be within 30 seconds of the server clock, or the request gets 401 timestamp_out_of_range. Keep your clock synced (NTP). GET /v1/status returns the server clock as server_time without a signature.
  • MK-Nonce is 16 bytes from a cryptographically secure random generator, as hex. Reusing one with the same key gets 401 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 POST safe 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
Canonical string (no trailing newline)
myntkaup-public-v1
mk_test_0123456789abcdefghij
1767225600123
00112233445566778899aabbccddeeff
POST
/v1/quotes
82c3b1d587da21ee0fd5d8e59279cdea07a26416867024da753a066318f74e5b
Headers
POST /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.

sign-headers.mjs
// 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"),
  };
}
Usage
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,
});

Download sign-headers.mjs

Python

Uses the cryptography package.

sign_headers.py
"""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,
    }
Usage
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"},
)

Download sign_headers.py

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.

Usage
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".

sign-headers.sh
#!/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"

Download sign-headers.sh

When authentication fails

StatusCodeUsual cause
401invalid_signatureWrong key id or environment, a body that differs from the signed one, or a canonical string with a trailing newline.
401timestamp_out_of_rangeThe clock is more than 30 seconds off, or a signed request was sent late.
401nonce_reusedA request was re-sent without being signed again.
401key_expiredThe key passed its expiry date.
401key_revokedThe key was revoked in the webapp.
403ip_not_allowedThe key has an IP allowlist and the request came from another address.
403insufficient_scopeThe 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 trade and giftcards:create only 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 .env file 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.