Authentication
The Skipo API uses a two-tier scheme. Which tier applies depends on the sensitivity of the operation:
- Tier 1 — bearer key: reads and non-sensitive writes.
- Tier 2 — per-request signed JWT: moving money.
The three pieces of a key
A key puts three values in play, and they go in different places. Skipo issues only the first two; you generate the third, and its private half never leaves your side.
| Value | Goes in | Secret? | Origin |
|---|---|---|---|
| Key secret | Authorization: Bearer <secret> (Tier 1) | Yes — shown once | Issued by Skipo when you create the key |
| Prefix | X-API-Key header and the JWT sub claim (Tier 2) | No, public | Issued by Skipo — and derivable from the secret |
| Signing key | Signs the Tier-2 JWT | Yes — the private half is never sent | You generate it; you upload only the public half (SPKI) |
The secret is skp_live_ + 32 random characters + a 6-character checksum. The
prefix is skp_live_ + the first 12 of those 32 — that is, exactly the first
21 characters of the secret:
skp_live_aB3dE5gH7jK9mN2pQ4rS6tU8vW0xY1zA9bC3dE ← secret (Tier 1)
skp_live_aB3dE5gH7jK9 ← prefix (Tier 2, public)
You do not need to store it separately: derive it from the secret when you need it.
Tier 1 — Bearer key
Most operations authenticate with a secret key in the Authorization header:
- curl
- Node.js
- Python
curl https://api.skipo.com/v2/balances \
-H "Authorization: Bearer $SKIPO_BEARER_KEY"
const res = await fetch(`${base}/v2/balances`, {
headers: { Authorization: `Bearer ${process.env.SKIPO_BEARER_KEY}` },
})
console.log(await res.json())
import os, urllib.request, json
req = urllib.request.Request(
f"{base}/v2/balances",
headers={"Authorization": f"Bearer {os.environ['SKIPO_BEARER_KEY']}"},
)
with urllib.request.urlopen(req) as resp:
print(json.load(resp))
About bearer keys:
- Format
skp_live_…/skp_test_…: 32 random characters (0-9A-Za-z) generated with a CSPRNG, plus a 6-character checksum that lets you discard a mistyped key without calling the API. - Shown only once when created; Skipo stores only their SHA-256 hash.
- Each key has scopes that limit which operations it can access (see below).
- Each key optionally takes an IP allowlist. If you set one, requests
from any other source are rejected with
ip_not_allowed, on both the bearer and the signed path. If you don't set one, no IP restriction applies.
Tier 2 — Per-request signed JWT
Only these two operations require a signature. Everything else is Tier 1:
| Operation | Endpoint | Scope |
|---|---|---|
| Execute a quote | POST /v2/orders | trading:write |
| Create a withdrawal | POST /v2/withdrawals | transfers:write |
The tier is decided by money movement, not by the HTTP verb. For example,
PATCH /v2/contacts/{contactId} only edits an alias: it is Tier 1 and
authenticates with the bearer key. The same goes for POST /v2/quotes, which
prices a trade but does not execute it.
If you sign a Tier-1 endpoint, the API answers 401 unauthorized with
reason: "signed_jwt_on_bearer_route". Your key is fine — the signature is what
does not belong. Resend the request with Authorization: Bearer <secret> and no
X-API-Key.
These operations require, instead of the bearer key, two headers: the public prefix of your key and a signed JWT that proves you generated this specific request and that no one tampered with it:
X-API-Key: skp_live_aB3dE5gH7jK9 # the PREFIX (21 characters), not the secret
Authorization: Bearer <signed JWT> # the JWT, not the key secret
On Tier 2 the secret never travels: X-API-Key carries the prefix and
Authorization carries the JWT. Put the full secret in X-API-Key and it
resolves no key, returning 401 unauthorized with
detail: "Unknown API key." — not invalid_signature.
When a signature does fail, the response carries a reason field naming the first
check that failed (full table).
The JWT is signed with your private key (Ed25519 by default, RS256 as an
alternative) and includes these claims:
| Claim | Value |
|---|---|
sub | The key's prefix (identical to the X-API-Key value). |
uri | "METHOD /path?query" — method, a single space, and the path with its query exactly as sent (e.g. "POST /v2/withdrawals"). |
nonce | A per-request unique value (e.g. a UUID v4). Prevents replay. |
iat | Issued at (epoch, seconds). |
exp | Expires at. Must satisfy exp − iat ≤ 60. |
bodyHash | SHA-256 in hex of the raw bytes of the body. |
The bodyHash is computed over the raw bytes of the body, exactly as they are
transmitted. Serialize the body only once, compute the hash over those
bytes, and send those same bytes. If you re-serialize the JSON after signing,
the bodyHash no longer matches and the request is rejected with
invalid_signature.
Example — signing a withdrawal
- Node.js
- Python
import { SignJWT, importPKCS8 } from 'jose'
import { createHash, randomUUID } from 'node:crypto'
const method = 'POST'
const path = '/v2/withdrawals'
// Serializa el cuerpo UNA vez; estos bytes se hashean y se envían.
const body = JSON.stringify({ assetSymbol: 'BTC', amount: '0.05', contactId })
const bodyHash = createHash('sha256').update(Buffer.from(body, 'utf8')).digest('hex')
const now = Math.floor(Date.now() / 1000)
const key = await importPKCS8(process.env.SKIPO_PRIVATE_KEY_PEM, 'EdDSA')
const jwt = await new SignJWT({ uri: `${method} ${path}`, nonce: randomUUID(), bodyHash })
.setProtectedHeader({ alg: 'EdDSA' })
.setSubject(keyPrefix) // === X-API-Key
.setIssuedAt(now)
.setExpirationTime(now + 55) // exp − iat ≤ 60
.sign(key)
const res = await fetch(base + path, {
method,
headers: {
'X-API-Key': keyPrefix,
Authorization: `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
body, // los MISMOS bytes cubiertos por bodyHash
})
import hashlib, json, time, uuid, jwt
from cryptography.hazmat.primitives.serialization import load_pem_private_key
method, path = "POST", "/v2/withdrawals"
# Serializa el cuerpo UNA vez; estos bytes se hashean y se envían.
body = json.dumps({"assetSymbol": "BTC", "amount": "0.05", "contactId": contact_id}).encode()
body_hash = hashlib.sha256(body).hexdigest()
now = int(time.time())
private_key = load_pem_private_key(os.environ["SKIPO_PRIVATE_KEY_PEM"].encode(), password=None)
token = jwt.encode(
{
"sub": key_prefix, # === X-API-Key
"uri": f"{method} {path}",
"nonce": str(uuid.uuid4()),
"iat": now,
"exp": now + 55, # exp − iat ≤ 60
"bodyHash": body_hash,
},
private_key,
algorithm="EdDSA",
)
# Envía body como los MISMOS bytes cubiertos por bodyHash, con:
# X-API-Key: key_prefix + Authorization: Bearer <token>
Complete, runnable versions of these examples are in the examples/ directory.
Generating the key pair
You always generate the signing private key: Skipo only ever receives the public half (SPKI) and never sees the private one. There are two ways to do it, and the difference matters.
Either way, you can register the public key when you create the key — both are stored in the same operation — or add it later to a key that already exists.
Option A — on your machine (recommended)
The private key never passes through a browser.
# Private key (Ed25519, PKCS#8) — keep it secret
openssl genpkey -algorithm ed25519 -out skipo-signing-key.pem
# Public key (SPKI) — this is the one you upload to the dashboard
openssl pkey -in skipo-signing-key.pem -pubout -out skipo-signing-key.pub.pem
The system openssl on macOS is LibreSSL and does not support -algorithm ed25519. Install OpenSSL with Homebrew (brew install openssl) or generate the
pair with your language's utility (crypto.generateKeyPairSync('ed25519') in
Node, cryptography in Python).
Option B — in the browser, from the dashboard
The dashboard can generate the pair for you using the browser's Web Crypto API. The public half is uploaded and the private half is shown to you once so you can store it; it is never transmitted to or stored by Skipo.
The trade-off: the private key exists in the page's memory for the duration of the
process, so it inherits the security of that browser and its extensions. It is the
convenient route to get started, or for a skp_test_ key; for skp_live_ keys
that move money, prefer Option A.
Ed25519 in Web Crypto is not available in every browser. The dashboard checks before offering this option; if it is unavailable, use Option A.
Clocks and /v2/time
Signed requests are sensitive to clock skew: if your iat runs too far ahead of
the server's time, the request is rejected with
clock_skew (retryable). Check the server time with
GET /v2/time and sync before signing if you suspect a skew.
Key rotation
The two tiers rotate differently.
Bearer key — 7-day grace period. When you rotate it, Skipo issues a new
secret and the previous one keeps working for 7 days; after that it returns
key_expired. Deploy the new secret within that window.
Signing key — no grace period. A signing key goes from active to revoked immediately: the moment you revoke it, it stops verifying. You control the overlap yourself. The verifier tries every active signing key you have, so upload the new public key alongside the current one: both verify in parallel for as long as you leave both active. Migrate your signing to the new key, and only then revoke the old one.
Authentication errors
| Situation | code |
|---|---|
| Missing or unknown credential | unauthorized |
| Invalid signature or claim | invalid_signature |
| Clock running ahead | clock_skew |
Reused nonce | nonce_reused |
| Missing scope | insufficient_scope |
| IP not allowed | ip_not_allowed |
Scopes
Scopes are set when the key is created and limit what it can do:
| Scope | Allows |
|---|---|
accounts:read | Read account, balances, and movements. |
market_data:read | Read reference data (currencies, markets). |
transfers:read | Read withdrawals. |
transfers:write | Create withdrawals (requires signing). |
trading:read | Read conversions and orders. |
trading:write | Request quotes and confirm conversions (confirming requires signing). |
contacts:read | Read contacts. |
contacts:write | Edit a contact's alias/reference. |
webhooks:read | Reserved — not usable yet. No endpoint requires it today: webhook management is dashboard-only. Ticking it on a key enables nothing. |