Autenticación
La API de Skipo usa un esquema de dos niveles. El nivel depende de la sensibilidad de la operación:
- Nivel 1 — llave bearer: lecturas y escrituras no sensibles.
- Nivel 2 — JWT firmado por petición: movimiento de dinero.
Las tres piezas de una llave
Una llave pone en juego tres valores, y van en sitios distintos. Sólo los dos primeros los emite Skipo; el tercero lo generas tú y su mitad privada nunca sale de tu lado.
| Valor | Va en | ¿Secreto? | Origen |
|---|---|---|---|
| Secreto de la llave | Authorization: Bearer <secreto> (Nivel 1) | Sí — se muestra una sola vez | Lo emite Skipo al crear la llave |
| Prefijo | Cabecera X-API-Key y claim sub del JWT (Nivel 2) | No, es público | Lo emite Skipo — y es derivable del secreto |
| Clave de firma | Firma el JWT de Nivel 2 | Sí — la privada nunca se envía | La generas tú; a Skipo le subes sólo la pública (SPKI) |
El secreto es skp_live_ + 32 caracteres aleatorios + 6 de checksum. El prefijo
es skp_live_ + los 12 primeros de esos 32 — es decir, exactamente los 21
primeros caracteres del secreto:
skp_live_aB3dE5gH7jK9mN2pQ4rS6tU8vW0xY1zA9bC3dE ← secreto (Nivel 1)
skp_live_aB3dE5gH7jK9 ← prefijo (Nivel 2, público)
No necesitas guardarlo aparte: puedes derivarlo del secreto cuando lo necesites.
Nivel 1 — Llave bearer
La mayoría de las operaciones se autentican con una llave secreta en la cabecera
Authorization:
- 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))
Sobre las llaves bearer:
- Formato
skp_live_…/skp_test_…: 32 caracteres aleatorios (0-9A-Za-z) generados con un CSPRNG, más un checksum de 6 que permite descartar una llave mal copiada sin llamar a la API. - Se muestran una sola vez al crearlas; Skipo almacena solo su hash SHA-256.
- Cada llave tiene scopes que limitan a qué operaciones accede (ver abajo).
- Cada llave admite, de forma opcional, una lista de IPs permitidas. Si la
defines, las peticiones desde cualquier otro origen se rechazan con
ip_not_allowed, tanto en el nivel bearer como en el firmado. Si no la defines, no se aplica ninguna restricción por IP.
Nivel 2 — JWT firmado por petición
Sólo estas dos operaciones exigen firma. Todas las demás son de Nivel 1:
| Operación | Endpoint | Scope |
|---|---|---|
| Ejecutar una cotización | POST /v2/orders | trading:write |
| Crear un retiro | POST /v2/withdrawals | transfers:write |
El nivel lo determina el movimiento de dinero, no el verbo HTTP. Por ejemplo,
PATCH /v2/contacts/{contactId} sólo edita un alias: es de Nivel 1 y se
autentica con la llave bearer. Lo mismo aplica a POST /v2/quotes, que cotiza
pero no ejecuta.
Si firmas un endpoint de Nivel 1, la API responde 401 unauthorized con
reason: "signed_jwt_on_bearer_route". Tu llave está bien — lo que sobra es la
firma. Reenvía la petición con Authorization: Bearer <secreto> y sin
X-API-Key.
Estas operaciones exigen, en lugar de la llave bearer, dos cabeceras: el prefijo público de tu llave y un JWT firmado que prueba que tú generaste esa petición concreta y que nadie la alteró:
X-API-Key: skp_live_aB3dE5gH7jK9 # el PREFIJO (21 caracteres), no el secreto
Authorization: Bearer <JWT firmado> # el JWT, no el secreto de la llave
En Nivel 2 el secreto no viaja: X-API-Key lleva el prefijo y Authorization
lleva el JWT. Si pones el secreto completo en X-API-Key, no resuelve ninguna
llave y recibes 401 unauthorized con
detail: "Unknown API key." — no invalid_signature.
Cuando algo falla en la firma, la respuesta trae un campo reason que nombra la
primera comprobación que falló (tabla completa).
El JWT se firma con tu clave privada (Ed25519 por defecto, RS256 como
alternativa) e incluye estos claims:
| Claim | Valor |
|---|---|
sub | El prefijo de la llave (idéntico al valor de X-API-Key). |
uri | "MÉTODO /ruta?query" — método, un espacio, y la ruta con su query exactamente como se envía (p. ej. "POST /v2/withdrawals"). |
nonce | Valor único por petición (p. ej. un UUID v4). Previene replay. |
iat | Emitido en (epoch, segundos). |
exp | Expira en. Debe cumplir exp − iat ≤ 60. |
bodyHash | SHA-256 en hex de los bytes crudos del cuerpo. |
El bodyHash se calcula sobre los bytes crudos del cuerpo, exactamente como se
transmiten. Serializa el cuerpo una sola vez, calcula el hash sobre esos
bytes, y envía esos mismos bytes. Si reserializas el JSON después de firmar,
el bodyHash deja de coincidir y la petición se rechaza con
invalid_signature.
Ejemplo — firmar un retiro
- 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>
Versiones completas y ejecutables de estos ejemplos están en el directorio examples/.
Generar el par de llaves
La clave privada de firma siempre la generas tú: Skipo sólo recibe la pública (SPKI) y nunca ve la privada. Hay dos formas de hacerlo, y la diferencia importa.
Sea cual sea la que elijas, puedes registrar la clave pública al crear la llave —ambas quedan guardadas en la misma operación— o añadirla después a una llave que ya existe.
Opción A — en tu máquina (recomendada)
La privada no pasa nunca por un navegador.
# Clave privada (Ed25519, PKCS#8) — guárdala en secreto
openssl genpkey -algorithm ed25519 -out skipo-signing-key.pem
# Clave pública (SPKI) — esta es la que subes al panel
openssl pkey -in skipo-signing-key.pem -pubout -out skipo-signing-key.pub.pem
El openssl del sistema en macOS es LibreSSL y no soporta -algorithm ed25519.
Instala OpenSSL con Homebrew (brew install openssl) o genera el par con la
utilidad de tu lenguaje (crypto.generateKeyPairSync('ed25519') en Node,
cryptography en Python).
Opción B — en el navegador, desde el panel
El panel puede generar el par por ti con la Web Crypto API del navegador. La pública se sube y la privada se te muestra una sola vez para que la guardes; no se transmite ni se almacena en Skipo.
El compromiso: la privada existe en la memoria de la página mientras dura el
proceso, así que hereda la seguridad de ese navegador y sus extensiones. Es la vía
cómoda para empezar o para una llave skp_test_; para llaves skp_live_ que
mueven dinero, prefiere la Opción A.
Ed25519 en Web Crypto no está en todos los navegadores. El panel lo comprueba antes de ofrecer esta opción; si no está disponible, usa la Opción A.
Relojes y /v2/time
Las peticiones firmadas son sensibles al desfase de reloj: si tu iat va muy por
delante de la hora del servidor, la petición se rechaza con
clock_skew (reintentable). Consulta la hora del servidor
con GET /v2/time y sincroniza antes de firmar si sospechas de un desfase.
Rotación de llaves
Los dos niveles rotan de forma distinta.
Llave bearer — período de gracia de 7 días. Al rotarla, Skipo emite un
secreto nuevo y el anterior sigue funcionando durante 7 días; pasado ese
plazo devuelve key_expired. Despliega el secreto nuevo
dentro de esa ventana.
Llave de firma — sin período de gracia. Una llave de firma pasa de activa a revocada de inmediato: al revocarla deja de verificar en el acto. La superposición la controlas tú. El verificador prueba todas tus llaves de firma activas, así que sube la clave pública nueva junto a la actual: ambas verifican en paralelo mientras las dos sigan activas. Migra tu firma a la nueva y recién entonces revoca la anterior.
Errores de autenticación
| Situación | code |
|---|---|
| Falta o es desconocida la credencial | unauthorized |
| Firma o claim inválidos | invalid_signature |
| Reloj adelantado | clock_skew |
nonce reutilizado | nonce_reused |
| Falta un scope | insufficient_scope |
| IP no permitida | ip_not_allowed |
Scopes
Los scopes se fijan al crear la llave y acotan lo que puede hacer:
| Scope | Permite |
|---|---|
accounts:read | Leer cuenta, balances y movimientos. |
market_data:read | Leer datos de referencia (monedas, mercados). |
transfers:read | Leer retiros. |
transfers:write | Crear retiros (requiere firma). |
trading:read | Leer conversiones y órdenes. |
trading:write | Pedir cotizaciones y confirmar conversiones (confirmar requiere firma). |
contacts:read | Leer contactos. |
contacts:write | Editar el alias/referencia de un contacto. |
webhooks:read | Reservado — todavía no utilizable. Hoy ningún endpoint lo exige: la gestión de webhooks es solo desde el panel. Marcarlo en una llave no habilita nada. |