Saltar al contenido principal

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.

ValorVa en¿Secreto?Origen
Secreto de la llaveAuthorization: Bearer <secreto> (Nivel 1)Sí — se muestra una sola vezLo emite Skipo al crear la llave
PrefijoCabecera X-API-Key y claim sub del JWT (Nivel 2)No, es públicoLo emite Skipo — y es derivable del secreto
Clave de firmaFirma el JWT de Nivel 2Sí — la privada nunca se envíaLa generas tú; a Skipo le subes sólo la pública (SPKI)
El prefijo son los 21 primeros caracteres del secreto

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

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ónEndpointScope
Ejecutar una cotizaciónPOST /v2/orderstrading:write
Crear un retiroPOST /v2/withdrawalstransfers:write
«Escritura» no implica firma

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
Ninguna de las dos cabeceras lleva 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:

ClaimValor
subEl 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").
nonceValor único por petición (p. ej. un UUID v4). Previene replay.
iatEmitido en (epoch, segundos).
expExpira en. Debe cumplir exp − iat ≤ 60.
bodyHashSHA-256 en hex de los bytes crudos del cuerpo.
Cuerpo crudo

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

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

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
macOS

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.

Soporte del navegador

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óncode
Falta o es desconocida la credencialunauthorized
Firma o claim inválidosinvalid_signature
Reloj adelantadoclock_skew
nonce reutilizadononce_reused
Falta un scopeinsufficient_scope
IP no permitidaip_not_allowed

Scopes

Los scopes se fijan al crear la llave y acotan lo que puede hacer:

ScopePermite
accounts:readLeer cuenta, balances y movimientos.
market_data:readLeer datos de referencia (monedas, mercados).
transfers:readLeer retiros.
transfers:writeCrear retiros (requiere firma).
trading:readLeer conversiones y órdenes.
trading:writePedir cotizaciones y confirmar conversiones (confirmar requiere firma).
contacts:readLeer contactos.
contacts:writeEditar el alias/referencia de un contacto.
webhooks:readReservado — 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.