Skip to main content

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.

ValueGoes inSecret?Origin
Key secretAuthorization: Bearer <secret> (Tier 1)Yes — shown onceIssued by Skipo when you create the key
PrefixX-API-Key header and the JWT sub claim (Tier 2)No, publicIssued by Skipo — and derivable from the secret
Signing keySigns the Tier-2 JWTYes — the private half is never sentYou generate it; you upload only the public half (SPKI)
The prefix is the first 21 characters of the secret

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 https://api.skipo.com/v2/balances \
-H "Authorization: Bearer $SKIPO_BEARER_KEY"

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:

OperationEndpointScope
Execute a quotePOST /v2/orderstrading:write
Create a withdrawalPOST /v2/withdrawalstransfers:write
"Write" does not imply signed

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
Neither header carries 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:

ClaimValue
subThe 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").
nonceA per-request unique value (e.g. a UUID v4). Prevents replay.
iatIssued at (epoch, seconds).
expExpires at. Must satisfy exp − iat ≤ 60.
bodyHashSHA-256 in hex of the raw bytes of the body.
Raw 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

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
})
note

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.

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
macOS

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.

Browser support

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

Situationcode
Missing or unknown credentialunauthorized
Invalid signature or claiminvalid_signature
Clock running aheadclock_skew
Reused noncenonce_reused
Missing scopeinsufficient_scope
IP not allowedip_not_allowed

Scopes

Scopes are set when the key is created and limit what it can do:

ScopeAllows
accounts:readRead account, balances, and movements.
market_data:readRead reference data (currencies, markets).
transfers:readRead withdrawals.
transfers:writeCreate withdrawals (requires signing).
trading:readRead conversions and orders.
trading:writeRequest quotes and confirm conversions (confirming requires signing).
contacts:readRead contacts.
contacts:writeEdit a contact's alias/reference.
webhooks:readReserved — not usable yet. No endpoint requires it today: webhook management is dashboard-only. Ticking it on a key enables nothing.