# 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. :::