# Skipo API
> The Skipo public API — reference, guides, and SDKs
This file contains all documentation content in a single document following the llmstxt.org standard.
## Autenticación
import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
# 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.
## Nivel 1 — Llave _bearer_
La mayoría de las operaciones se autentican con una llave secreta en la cabecera
`Authorization`:
```bash
curl https://api.skipo.com/v2/balances \
-H "Authorization: Bearer $SKIPO_BEARER_KEY"
```
```js
const res = await fetch(`${base}/v2/balances`, {
headers: { Authorization: `Bearer ${process.env.SKIPO_BEARER_KEY}` },
})
console.log(await res.json())
```
```python
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 bytes aleatorios + _checksum_ de 6).
- 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).
- Las llaves con scopes de escritura pueden exigir una **lista de IPs permitidas**.
## Nivel 2 — JWT firmado por petición
Las operaciones que **mueven dinero** 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_XXXXXXXXXXXX # el prefijo público de la llave
Authorization: Bearer
```
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. |
:::warning[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`](/errors/invalid_signature).
:::
### Ejemplo — firmar un retiro
```js
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({ currencyId: 'BTC', quantity: '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
})
```
```python
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({"currencyId": "BTC", "quantity": "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
```
:::note
Versiones completas y ejecutables de estos ejemplos (que CI corre contra el
entorno de dev) están en el directorio [`examples/`](https://gitlab.com/skipo-engine/apps/api-public-docs/-/tree/main/examples).
:::
## Generar el par de llaves
Durante el _onboarding_ generas un par de llaves y subes **solo la clave pública**
(SPKI) desde el panel. Skipo nunca ve tu clave privada.
```bash
# 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
```
:::note[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).
:::
## 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`](/errors/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
Al rotar una llave de firma hay una **ventana de doble validez**: la llave nueva y
la anterior verifican en paralelo durante el período de gracia, para que puedas
desplegar sin cortar peticiones en vuelo. Sube la clave pública nueva, migra tu
firma, y revoca la anterior cuando termines.
## Errores de autenticación
| Situación | `code` |
|---|---|
| Falta o es desconocida la credencial | [`unauthorized`](/errors/unauthorized) |
| Firma o claim inválidos | [`invalid_signature`](/errors/invalid_signature) |
| Reloj adelantado | [`clock_skew`](/errors/clock_skew) |
| `nonce` reutilizado | [`nonce_reused`](/errors/nonce_reused) |
| Falta un scope | [`insufficient_scope`](/errors/insufficient_scope) |
| IP no permitida | [`ip_not_allowed`](/errors/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). |
| `conversions:read` | Leer conversiones y órdenes. |
| `conversions:write` | Pedir cotizaciones y confirmar conversiones. |
| `contacts:read` | Leer contactos. |
| `contacts:write` | Editar el alias/referencia de un contacto. |
| `webhooks:read` | Leer entregas de webhooks. |
---
## Errores
La API devuelve errores en formato **`application/problem+json`**
([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)). Todo error comparte una
estructura estable y predecible:
```json
{
"type": "https://docs.skipo.com/errors/insufficient_funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "The account balance is lower than the requested amount.",
"code": "insufficient_funds",
"retryable": false,
"instance": "/v2/withdrawals"
}
```
## Campos
| Campo | Descripción |
|---|---|
| `type` | URI estable que documenta el error (apunta a `docs.skipo.com/errors/{code}`). |
| `title` | Resumen legible del tipo de error. |
| `status` | Código HTTP. |
| `detail` | Descripción específica de _esta_ ocurrencia. |
| `code` | **Código estable** legible por máquina — úsalo para ramificar tu lógica. |
| `retryable` | `true` si reintentar la misma petición puede tener éxito. |
| `instance` | Ruta de la petición que falló. |
:::tip
Ramifica tu manejo de errores sobre `code`, no sobre `title` ni `detail`
(que pueden cambiar de redacción o traducirse). El `code` es un contrato estable.
:::
## Códigos comunes
| `code` | HTTP | ¿Reintentable? |
|---|---|---|
| [`unauthorized`](/errors/unauthorized) | 401 | no |
| [`invalid_signature`](/errors/invalid_signature) | 401 | no |
| [`insufficient_scope`](/errors/insufficient_scope) | 403 | no |
| [`not_found`](/errors/not_found) | 404 | no |
| [`validation_error`](/errors/validation_error) | 400 | no |
| [`unprocessable`](/errors/unprocessable) | 422 | no |
| [`rate_limited`](/errors/rate_limited) | 429 | sí |
| [`internal_error`](/errors/internal_error) | 500 | sí |
El **[catálogo completo de errores](/errors)** documenta los 22 códigos, cada uno
con su propia página en `docs.skipo.com/errors/{code}` — la misma URI que aparece
en el campo `type` de la respuesta.
---
## Paginación
Las colecciones se devuelven dentro de una envoltura consistente con un objeto
`data` (los elementos) y un objeto `pagination` (los metadatos de navegación):
```json
{
"data": [
{ "id": "wd_123", "status": "COMPLETED" },
{ "id": "wd_124", "status": "PENDING" }
],
"pagination": {
"page": 1,
"pageSize": 25,
"total": 132
}
}
```
## Parámetros
| Parámetro | Descripción |
|---|---|
| `page` | Número de página (empieza en 1). |
| `pageSize` | Elementos por página. |
## Filtros
En v2, los filtros se expresan como parámetros de _query_ en lugar de rutas
especiales. Por ejemplo, para buscar por identificador de cliente usa
`?clOrdId=…` en vez de una ruta dedicada `/clOrdId/…`.
Consulta la [Referencia de la API](/reference) para ver los filtros disponibles
en cada colección.
:::info[Compatibilidad futura]
La envoltura de paginación está diseñada para admitir cursores en el futuro sin
romper el contrato actual basado en páginas.
:::
---
## Límites de tasa
Los límites se aplican **por llave de API**. Cada operación tiene un _peso_ según
su costo; las operaciones más caras consumen más del presupuesto.
## Cabeceras
Cada respuesta incluye las cabeceras estándar (borrador IETF) y sus equivalentes
heredadas:
| Cabecera | Significado |
|---|---|
| `RateLimit-Limit` | Límite de la ventana actual. |
| `RateLimit-Remaining` | Peticiones restantes en la ventana. |
| `RateLimit-Reset` | Segundos hasta que se reinicia la ventana. |
## Respuesta `429`
Si superas el límite recibes un `429 Too Many Requests` con cuerpo
[`application/problem+json`](/concepts/errors) y una cabecera `Retry-After`:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 3
Content-Type: application/problem+json
```
```json
{
"type": "https://docs.skipo.com/errors/rate_limited",
"title": "Too Many Requests",
"status": 429,
"code": "rate_limited",
"retryable": true
}
```
**Respeta siempre `Retry-After`** y aplica _backoff_ exponencial ante `429`.
## Cuotas mensuales
Además del límite por ventana, cada llave tiene una **cuota mensual** según su
plan (free / standard / premium / internal). Las cuotas se reconcilian a diario.
:::info[En construcción]
Los valores exactos de cada plan se publicarán aquí. Consulta con tu contacto
comercial en Skipo si necesitas límites mayores.
:::
---
## Webhooks
En lugar de hacer _polling_, puedes registrar una URL para recibir **webhooks**
cuando cambian tus transacciones. Cada entrega va **firmada** para que puedas
verificar que proviene de Skipo y que no fue alterada.
## Envoltura del evento
Cada webhook tiene una envoltura estable inspirada en el modelo de Fireblocks v2:
```json
{
"id": "evt_01H…",
"webhookId": "whk_01H…",
"eventType": "withdrawal.status.updated",
"resourceId": "wd_123",
"createdAt": "2026-07-10T12:34:56.000Z",
"data": {
"id": "wd_123",
"status": "COMPLETED"
}
}
```
El objeto `data` es la misma forma que devuelve el `GET` del recurso en v2.
## Catálogo de eventos
Los tipos de evento usan notación de puntos y admiten comodines al suscribirte
(`*`, o por categoría como `withdrawal.*`):
| Evento | Cuándo |
|---|---|
| `withdrawal.created` | Se crea un retiro. |
| `withdrawal.status.updated` | Un retiro alcanza un estado terminal (COMPLETED / FAILED). |
| `withdrawal.refund.completed` | Se libera el saldo congelado de un retiro cancelado. |
| `deposit.created` | Se crea un depósito. |
| `deposit.status.updated` | Un depósito alcanza un estado terminal. |
## Verificación de la firma
Cada entrega incluye una firma **JWS detached** (EdDSA/Ed25519) en la cabecera
`Skipo-Webhook-Signature`, con una marca de tiempo firmada en el _header_ crítico
`skipo.io/iat`. Verifícala con la clave pública publicada en el **JWKS**:
```
GET https://api.skipo.com/v2/.well-known/webhook-jwks.json
```
Pasos de verificación:
1. Obtén la clave pública desde el JWKS (cachéala).
2. Reconstruye la firma _detached_ con el cuerpo crudo recibido.
3. Verifica la firma y comprueba que `skipo.io/iat` esté dentro de una tolerancia
razonable (previene _replay_).
:::warning
Verifica siempre la firma contra los **bytes crudos** del cuerpo, antes de
parsear el JSON. Nunca proceses un webhook cuya firma no valida.
:::
## Reintentos
Si tu endpoint no responde `2xx`, Skipo reintenta con un calendario de _backoff_.
Si un endpoint falla de forma sostenida, se **suspende** automáticamente y puedes
reactivarlo desde el panel.
:::info[En construcción]
Ejemplos de verificación de firma en varios lenguajes y la guía de reenvío
(_resend_) se añadirán en esta página.
:::
---
## clock_skew
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `clock_skew`
El reloj del cliente está demasiado adelantado respecto al servidor.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 401 | `clock_skew` | sí |
## Cuándo ocurre
El claim `iat` supera la hora del servidor por más de la tolerancia permitida.
## Cómo resolverlo
Sincroniza tu reloj con `GET /v2/time` y vuelve a firmar. Es reintentable una vez corregido el desfase.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/clock_skew",
"title": "Clock skew too large",
"status": 401,
"code": "clock_skew",
"retryable": true,
"detail": "El reloj del cliente está demasiado adelantado respecto al servidor."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## conflict
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `conflict`
La petición choca con el estado actual del recurso.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 409 | `conflict` | no |
## Cuándo ocurre
Un cambio que contradice el estado presente (por ejemplo un nombre duplicado).
## Cómo resolverlo
Vuelve a leer el recurso, ajusta la petición al estado actual y reintenta.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/conflict",
"title": "Conflict",
"status": 409,
"code": "conflict",
"retryable": false,
"detail": "La petición choca con el estado actual del recurso."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## downstream_unavailable
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `downstream_unavailable`
Un servicio del que depende la operación no está disponible.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 502 | `downstream_unavailable` | sí |
## Cuándo ocurre
Un componente interno o proveedor está caído o no responde.
## Cómo resolverlo
Reintenta con backoff. Suele resolverse solo en poco tiempo.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/downstream_unavailable",
"title": "Downstream service unavailable",
"status": 502,
"code": "downstream_unavailable",
"retryable": true,
"detail": "Un servicio del que depende la operación no está disponible."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## idempotency_conflict
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `idempotency_conflict`
Se reutilizó una clave de idempotencia con un cuerpo distinto.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 409 | `idempotency_conflict` | no |
## Cuándo ocurre
Enviaste la misma clave de idempotencia con parámetros diferentes a la petición original.
## Cómo resolverlo
Usa una clave de idempotencia nueva para una petición distinta, o reenvía exactamente el mismo cuerpo.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/idempotency_conflict",
"title": "Idempotency conflict",
"status": 409,
"code": "idempotency_conflict",
"retryable": false,
"detail": "Se reutilizó una clave de idempotencia con un cuerpo distinto."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## Catálogo de errores
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# Catálogo de errores
Toda respuesta de error usa el formato [`application/problem+json` (RFC 9457)](/concepts/errors).
El campo `type` apunta a la página de este catálogo correspondiente a su `code`:
`https://docs.skipo.com/errors/{code}`. El `code` es un **contrato estable** —
ramifica tu lógica sobre él.
| `code` | HTTP | ¿Reintentable? | Título |
|---|---|---|---|
| [`validation_error`](/errors/validation_error) | 400 | no | Validation error |
| [`unauthorized`](/errors/unauthorized) | 401 | no | Unauthorized |
| [`invalid_signature`](/errors/invalid_signature) | 401 | no | Invalid signature |
| [`clock_skew`](/errors/clock_skew) | 401 | sí | Clock skew too large |
| [`nonce_reused`](/errors/nonce_reused) | 401 | sí | Nonce already used |
| [`key_expired`](/errors/key_expired) | 401 | no | API key expired |
| [`key_revoked`](/errors/key_revoked) | 401 | no | API key revoked |
| [`insufficient_scope`](/errors/insufficient_scope) | 403 | no | Insufficient scope |
| [`ip_not_allowed`](/errors/ip_not_allowed) | 403 | no | IP address not allowed |
| [`two_factor_required`](/errors/two_factor_required) | 403 | no | Two-factor required |
| [`permission_denied`](/errors/permission_denied) | 403 | no | Permission denied |
| [`not_found`](/errors/not_found) | 404 | no | Resource not found |
| [`conflict`](/errors/conflict) | 409 | no | Conflict |
| [`idempotency_conflict`](/errors/idempotency_conflict) | 409 | no | Idempotency conflict |
| [`key_limit_reached`](/errors/key_limit_reached) | 409 | no | API key limit reached |
| [`quotation_expired`](/errors/quotation_expired) | 410 | sí | Quotation expired |
| [`unprocessable`](/errors/unprocessable) | 422 | no | Unprocessable request |
| [`rate_limited`](/errors/rate_limited) | 429 | sí | Rate limit exceeded |
| [`internal_error`](/errors/internal_error) | 500 | sí | Internal server error |
| [`downstream_unavailable`](/errors/downstream_unavailable) | 502 | sí | Downstream service unavailable |
| [`maintenance`](/errors/maintenance) | 503 | sí | Service under maintenance |
| [`service_degraded`](/errors/service_degraded) | 503 | sí | Service degraded |
---
## insufficient_scope
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `insufficient_scope`
La llave no tiene el scope requerido para la operación.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 403 | `insufficient_scope` | no |
## Cuándo ocurre
Llamaste a un endpoint que exige un scope que la llave no incluye (ver `requiredScopes` en la respuesta).
## Cómo resolverlo
Crea una llave con los scopes necesarios. Los scopes se fijan al crear la llave y no se amplían después.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/insufficient_scope",
"title": "Insufficient scope",
"status": 403,
"code": "insufficient_scope",
"retryable": false,
"detail": "La llave no tiene el scope requerido para la operación."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## internal_error
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `internal_error`
Ocurrió un error inesperado en Skipo.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 500 | `internal_error` | sí |
## Cuándo ocurre
Fallo no controlado del lado del servidor.
## Cómo resolverlo
Reintenta con backoff. Si persiste, contacta a Skipo con el `X-Request-Id` de la respuesta.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/internal_error",
"title": "Internal server error",
"status": 500,
"code": "internal_error",
"retryable": true,
"detail": "Ocurrió un error inesperado en Skipo."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## invalid_signature
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `invalid_signature`
El JWT firmado no verifica contra la clave pública registrada, o alguno de sus claims no coincide con la petición.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 401 | `invalid_signature` | no |
## Cuándo ocurre
Firma incorrecta, `sub`/`uri`/`bodyHash` que no coinciden, `exp - iat > 60s`, o falta el `nonce`.
## Cómo resolverlo
Reconstruye el JWT: `sub` = prefijo de la llave, `uri` = `"MÉTODO /ruta?query"` exacto, `bodyHash` = SHA-256 hex de los bytes crudos del cuerpo, `exp ≤ iat + 60`. Firma con la clave privada cuyo par público subiste.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/invalid_signature",
"title": "Invalid signature",
"status": 401,
"code": "invalid_signature",
"retryable": false,
"detail": "El JWT firmado no verifica contra la clave pública registrada, o alguno de sus claims no coincide con la petición."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## ip_not_allowed
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `ip_not_allowed`
La IP de origen no está en la lista de IPs permitidas de la llave.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 403 | `ip_not_allowed` | no |
## Cuándo ocurre
La llave tiene una lista de IPs permitidas y la petición llegó desde otra dirección.
## Cómo resolverlo
Añade la IP saliente de tu servidor a la lista permitida de la llave, o llama desde una IP autorizada.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/ip_not_allowed",
"title": "IP address not allowed",
"status": 403,
"code": "ip_not_allowed",
"retryable": false,
"detail": "La IP de origen no está en la lista de IPs permitidas de la llave."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## key_expired
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `key_expired`
La llave de API alcanzó su fecha de expiración.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 401 | `key_expired` | no |
## Cuándo ocurre
La llave tenía una expiración configurada y ya pasó.
## Cómo resolverlo
Crea una nueva llave desde el panel y actualiza tu integración.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/key_expired",
"title": "API key expired",
"status": 401,
"code": "key_expired",
"retryable": false,
"detail": "La llave de API alcanzó su fecha de expiración."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## key_limit_reached
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `key_limit_reached`
La cuenta alcanzó su número máximo de llaves de API.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 409 | `key_limit_reached` | no |
## Cuándo ocurre
Intentaste crear una llave superando el tope de la cuenta.
## Cómo resolverlo
Revoca una llave que ya no uses antes de crear otra, o contacta a Skipo para ampliar el tope.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/key_limit_reached",
"title": "API key limit reached",
"status": 409,
"code": "key_limit_reached",
"retryable": false,
"detail": "La cuenta alcanzó su número máximo de llaves de API."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## key_revoked
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `key_revoked`
La llave de API fue revocada.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 401 | `key_revoked` | no |
## Cuándo ocurre
Alguien revocó la llave desde el panel (o fue revocada por seguridad).
## Cómo resolverlo
Crea una nueva llave y reemplázala en tu integración. Si no esperabas la revocación, contacta a Skipo.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/key_revoked",
"title": "API key revoked",
"status": 401,
"code": "key_revoked",
"retryable": false,
"detail": "La llave de API fue revocada."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## maintenance
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `maintenance`
La API está en mantenimiento.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 503 | `maintenance` | sí |
## Cuándo ocurre
Ventana de mantenimiento planificada; casi todas las rutas responden 503 excepto `/health` y `/v2/time`.
## Cómo resolverlo
Reintenta más tarde respetando `Retry-After`.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/maintenance",
"title": "Service under maintenance",
"status": 503,
"code": "maintenance",
"retryable": true,
"detail": "La API está en mantenimiento."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## nonce_reused
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `nonce_reused`
El `nonce` del JWT ya fue utilizado (protección anti-replay).
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 401 | `nonce_reused` | sí |
## Cuándo ocurre
Reenviaste un JWT ya visto, o generaste el mismo `nonce` dos veces dentro de la ventana de retención.
## Cómo resolverlo
Genera un `nonce` único por petición (por ejemplo un UUID v4) y vuelve a firmar.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/nonce_reused",
"title": "Nonce already used",
"status": 401,
"code": "nonce_reused",
"retryable": true,
"detail": "El `nonce` del JWT ya fue utilizado (protección anti-replay)."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## not_found
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `not_found`
El recurso solicitado no existe o no pertenece a tu cuenta.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 404 | `not_found` | no |
## Cuándo ocurre
Un identificador inexistente, o un recurso de otra cuenta (Skipo no revela su existencia).
## Cómo resolverlo
Verifica el identificador. Recuerda que solo puedes acceder a tus propios recursos.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/not_found",
"title": "Resource not found",
"status": 404,
"code": "not_found",
"retryable": false,
"detail": "El recurso solicitado no existe o no pertenece a tu cuenta."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## permission_denied
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `permission_denied`
La cuenta no está autorizada para esta operación.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 403 | `permission_denied` | no |
## Cuándo ocurre
Restricciones a nivel de cuenta impiden la acción, independientemente del scope de la llave.
## Cómo resolverlo
Contacta a Skipo si crees que tu cuenta debería tener acceso.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/permission_denied",
"title": "Permission denied",
"status": 403,
"code": "permission_denied",
"retryable": false,
"detail": "La cuenta no está autorizada para esta operación."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## quotation_expired
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `quotation_expired`
La cotización que intentas confirmar ya expiró.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 410 | `quotation_expired` | sí |
## Cuándo ocurre
Confirmaste una conversión demasiado tarde; las cotizaciones tienen una vida corta.
## Cómo resolverlo
Pide una cotización nueva (`POST /v2/quotes`) y confírmala de inmediato.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/quotation_expired",
"title": "Quotation expired",
"status": 410,
"code": "quotation_expired",
"retryable": true,
"detail": "La cotización que intentas confirmar ya expiró."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## rate_limited
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `rate_limited`
Superaste el límite de tasa o la cuota de la llave/cuenta.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 429 | `rate_limited` | sí |
## Cuándo ocurre
Demasiadas peticiones en la ventana, o se agotó la cuota diaria.
## Cómo resolverlo
Respeta la cabecera `Retry-After` y aplica backoff exponencial. Consulta la guía de Límites de tasa.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/rate_limited",
"title": "Rate limit exceeded",
"status": 429,
"code": "rate_limited",
"retryable": true,
"detail": "Superaste el límite de tasa o la cuota de la llave/cuenta."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## service_degraded
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `service_degraded`
El servicio opera de forma degradada y rechazó temporalmente la petición.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 503 | `service_degraded` | sí |
## Cuándo ocurre
Protección de carga o una dependencia degradada.
## Cómo resolverlo
Reintenta con backoff respetando `Retry-After`.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/service_degraded",
"title": "Service degraded",
"status": 503,
"code": "service_degraded",
"retryable": true,
"detail": "El servicio opera de forma degradada y rechazó temporalmente la petición."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## two_factor_required
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `two_factor_required`
La operación requiere verificación de segundo factor (2FA).
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 403 | `two_factor_required` | no |
## Cuándo ocurre
Operaciones sensibles de gestión de cuenta que exigen 2FA.
## Cómo resolverlo
Completa el paso 2FA desde el panel. La gestión sensible (crear llaves, cambiar destinos) es solo por el panel, no por la API.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/two_factor_required",
"title": "Two-factor required",
"status": 403,
"code": "two_factor_required",
"retryable": false,
"detail": "La operación requiere verificación de segundo factor (2FA)."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## unauthorized
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `unauthorized`
Falta la credencial o no es válida.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 401 | `unauthorized` | no |
## Cuándo ocurre
No enviaste `Authorization: Bearer`, la llave es desconocida, o una operación firmada llegó sin `X-API-Key` + JWT.
## Cómo resolverlo
Verifica que envías la llave correcta para el entorno (`skp_live_` vs `skp_test_`). Para operaciones que mueven dinero, usa el flujo firmado (nivel 2).
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"code": "unauthorized",
"retryable": false,
"detail": "Falta la credencial o no es válida."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## unprocessable
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `unprocessable`
La petición es sintácticamente válida pero no puede procesarse por una regla de negocio.
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 422 | `unprocessable` | no |
## Cuándo ocurre
Por ejemplo, saldo insuficiente o un destino no permitido.
## Cómo resolverlo
Revisa `detail` para la regla concreta que falló y ajusta la operación.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/unprocessable",
"title": "Unprocessable request",
"status": 422,
"code": "unprocessable",
"retryable": false,
"detail": "La petición es sintácticamente válida pero no puede procesarse por una regla de negocio."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## validation_error
{/* AUTO-GENERATED by scripts/gen-errors.mjs — do not edit; run `yarn gen:errors`. */}
# `validation_error`
La petición no cumple el esquema esperado (campo faltante, tipo incorrecto o valor inválido).
| HTTP | `code` | ¿Reintentable? |
|---|---|---|
| 400 | `validation_error` | no |
## Cuándo ocurre
El cuerpo, los parámetros de query o las cabeceras no pasan la validación de entrada.
## Cómo resolverlo
Revisa el campo `detail` (y cualquier extensión con la lista de errores) y corrige la petición. No reintentes sin cambios.
## Ejemplo
```json
{
"type": "https://docs.skipo.com/errors/validation_error",
"title": "Validation error",
"status": 400,
"code": "validation_error",
"retryable": false,
"detail": "La petición no cumple el esquema esperado (campo faltante, tipo incorrecto o valor inválido)."
}
```
---
Volver al [catálogo de errores](/errors) · Ver el [formato de errores](/concepts/errors).
---
## Entornos
Skipo expone dos entornos, distinguidos por el prefijo de la llave de API.
| Entorno | Base URL | Prefijo de llave |
|---|---|---|
| Producción | `https://api.skipo.com/v2` | `skp_live_…` |
| Pruebas | `https://api.skipo.com/v2` | `skp_test_…` |
:::note
El entorno se determina por la **llave**, no por la URL. Una llave `skp_test_…`
opera siempre sobre datos de prueba, aunque uses la misma base URL.
:::
## Cabeceras estándar
Toda respuesta incluye un identificador de traza para soporte y depuración:
- `X-Request-Id` — identificador único de la petición; inclúyelo al reportar
incidencias.
## Zona horaria y relojes
Las operaciones firmadas (nivel 2) son sensibles al desfase de reloj. Consulta la
hora del servidor con `GET /v2/time` antes de firmar si sospechas de un desfase.
---
## Quickstart
En esta guía harás tu primera llamada autenticada contra la API de Skipo usando
una llave _bearer_ de solo lectura.
## 1. Obtén una llave de API
Las llaves se generan desde el panel de Skipo (auto-servicio, con verificación
2FA). Cada llave nace con un conjunto de **scopes** y se muestra **una sola vez**:
- `skp_test_…` — entorno de pruebas.
- `skp_live_…` — entorno productivo.
Guarda la llave en un lugar seguro; Skipo solo almacena su hash.
## 2. Haz una llamada de lectura
Las lecturas y escrituras no sensibles se autentican con la llave en la cabecera
`Authorization: Bearer`:
```bash
curl https://api.skipo.com/v2/balances \
-H "Authorization: Bearer skp_live_your_key_here"
```
Respuesta (abreviada):
```json
{
"data": [
{ "currency": "USDC", "available": "1250.00", "total": "1250.00" }
]
}
```
## 3. Explora la referencia
Todas las operaciones disponibles están en la **[Referencia de la API](/reference)**,
con ejemplos de código en varios lenguajes generados desde la especificación
OpenAPI en vivo.
## Siguientes pasos
- **[Autenticación](/concepts/authentication)** — cómo firmar operaciones que
mueven dinero (retiros, conversiones).
- **[Webhooks](/concepts/webhooks)** — recibe notificaciones cuando cambian tus
transacciones en lugar de hacer _polling_.
- **[Límites de tasa](/concepts/rate-limits)** — cómo leer las cabeceras
`RateLimit-*` y evitar el `429`.
---
## Skipo API
Bienvenido a la documentación de la **API pública de Skipo** (`v2`). Con la API
puedes consultar balances y movimientos, obtener datos de mercado, crear retiros
y conversiones, y recibir **webhooks** firmados cuando cambian tus transacciones.
:::info[Versión]
Esta documentación describe la versión **v2**, servida bajo `https://api.skipo.com/v2`.
La versión v1 sigue disponible durante el período de migración — consulta
[Migración desde v1](/migration-from-v1).
:::
## Empieza aquí
- **[Primeros pasos → Quickstart](/getting-started/quickstart)** — tu primera
llamada autenticada en menos de cinco minutos.
- **[Autenticación](/concepts/authentication)** — el esquema de dos niveles:
llaves _bearer_ para lecturas y JWT firmado por petición para mover dinero.
- **[Referencia de la API](/reference)** — todas las operaciones, esquemas y
ejemplos, renderizados desde la especificación OpenAPI en vivo.
## Para agentes y herramientas de IA
La documentación se publica también en formato apto para modelos de lenguaje:
- [`/llms.txt`](pathname:///llms.txt) — índice curado de la documentación.
- [`/llms-full.txt`](pathname:///llms-full.txt) — todo el contenido en un solo archivo.
- [`/openapi.json`](pathname:///openapi.json) — la especificación OpenAPI cruda.
## Conceptos clave
| Concepto | Descripción |
|---|---|
| [Autenticación](/concepts/authentication) | Llaves `skp_live_…` / `skp_test_…` y firma EdDSA para operaciones sensibles. |
| [Límites de tasa](/concepts/rate-limits) | Límites por llave, cabeceras `RateLimit-*` y cuotas mensuales. |
| [Errores](/concepts/errors) | Respuestas `application/problem+json` (RFC 9457) con `code` estable. |
| [Paginación](/concepts/pagination) | Envoltura de paginación consistente en las colecciones. |
| [Webhooks](/concepts/webhooks) | Eventos firmados con JWS (EdDSA) y verificables vía JWKS. |
---
## Migración desde v1
La API **v2** es una reconstrucción desde cero. Convive con la v1 durante el
período de migración; la v1 se retirará de forma gradual y anunciada.
## Cambios principales
### Autenticación (cambio incompatible)
El esquema antiguo (`X-API-KEY` + firma RSA x509) **desaparece** en v2. En su
lugar se usa el [esquema de dos niveles](/concepts/authentication): llaves
_bearer_ `skp_live_…` para lecturas y JWT firmado con EdDSA para mover dinero.
El _onboarding_ es de auto-servicio (ya no se envía un certificado por correo).
### Errores
v2 devuelve siempre [`application/problem+json` (RFC 9457)](/concepts/errors) con
un `code` estable, en lugar de formatos de error heterogéneos.
### Renombrados de rutas
| v1 | v2 |
|---|---|
| `/converts` | `/conversions` |
| `/currencies` | `/balances` |
| `/supported_currencies` | `/currencies` |
| `/…/id/{id}` · `/…/clOrdId/{id}` | filtros por _query_ (`?id=` · `?clOrdId=`) |
### Novedades en v2
- **Datos de mercado y tasas** (`/v2/prices*`, `/v2/rates/*`).
- **Webhooks** firmados ([guía](/concepts/webhooks)).
- **Auto-servicio de llaves de API** (`/v2/api_keys*`).
- **Límites de tasa por llave** con cabeceras `RateLimit-*`.
## Calendario
La retirada de v1 está **guiada por datos**: se monitoriza el tráfico real de v1
y se procede por fases (aviso → _brownouts_ → apagado) cuando el tráfico se acerca
a cero. La v1 recibirá cabeceras `Deprecation` / `Sunset` antes de su retirada.
:::info[En construcción]
La tabla de equivalencias operación-por-operación (con diferencias de forma de
_payload_) se publicará aquí antes del inicio formal de la migración.
:::