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:
{
"id": "evt_01H…",
"webhookId": "whk_01H…",
"eventType": "fill.created",
"resourceId": "1096473",
"createdAt": 1783003522123,
"data": {
"id": "1096473",
"orderId": "clord_01H…",
"side": "BUY"
}
}
| Campo | Tipo | Descripción |
|---|---|---|
id | string | Id lógico del evento. Es tu clave de idempotencia. |
webhookId | string | El endpoint que recibió la entrega. |
eventType | string | Uno del catálogo. |
resourceId | string | null | Id del recurso afectado. |
createdAt | number | Instante del evento, en milisegundos desde epoch. |
data | object | Snapshot del recurso afectado. Sus nombres de campo no son necesariamente idénticos a los de la representación REST del mismo recurso. |
createdAt es un número, no una cadena ISO-8601La envoltura usa epoch en milisegundos (1783003522123), no "2026-07-10T12:34:56.000Z".
Versiones anteriores de esta página mostraban ISO-8601: la documentación estaba
equivocada, no el payload. Si tu integración parsea ese campo como cadena, corrígela.
La asimetría es deliberada: la envoltura la produce la plataforma de webhooks y usa
epoch (un entero, sin ambigüedad de zona horaria ni de formato); las marcas de tiempo
dentro de data (createdAt, executedAt) son ISO-8601, porque son
exactamente los mismos valores que devuelve la API REST. Regla práctica: fuera de data,
epoch; dentro de data, ISO-8601.
data no es la respuesta RESTdata es un snapshot del recurso afectado, pero no garantizamos que sus campos se llamen
igual que en la representación REST de ese mismo recurso. Divergencias conocidas hoy:
- el payload del webhook emite
assetdonde el recurso REST publicaassetSymbol; - emite un
assetIdcompuesto (por ejemplo"USDT-TRON") donde el recurso REST publica unnetworkSymbolsuelto.
Además, el payload de un fill no trae todos los campos que sí lleva el recurso REST del fill. Escribe tu integración contra el payload del webhook: no reutilices el parser de tus respuestas REST.
Idempotencia y reenvíos
id es el id lógico del evento y es estable entre reenvíos: si Skipo reintenta la
entrega, o el evento se reenvía, llega el mismo id. Deduplica con él y un webhook de
movimiento de dinero nunca se procesará dos veces.
Cada entrega llega además con estas cabeceras:
| Cabecera | Contenido |
|---|---|
skipo-webhook-delivery-id | Id de esta entrega. Es el mismo en todos los reintentos automáticos de una entrega; solo cambia en un reenvío. |
skipo-webhook-event | El eventType, para enrutar sin parsear el cuerpo. |
skipo-webhook-signature | La firma JWS detached (ver abajo). |
Catálogo de eventos
Son 7 eventos. Los tipos usan notación de puntos y admiten comodines al suscribirte:
exacto (withdrawal.created), por categoría (withdrawal.*) o global (*).
| Evento | Cuándo |
|---|---|
withdrawal.created | Se crea un retiro y se congela el saldo. |
withdrawal.status.updated | Un retiro cambia de estado (incluido el terminal COMPLETED / FAILED). |
deposit.created | Se detecta y registra un depósito. |
deposit.status.updated | Un depósito cambia de estado (incluido REVERSED). |
order.created | Se coloca una orden de conversión (al confirmar), con status: "NEW". |
order.status.updated | Una orden cambia de estado (PARTIALLY_FILLED → FILLED / FAILED). |
fill.created | Se ejecuta un fill contra una orden. |
Las categorías suscribibles son exactamente withdrawal.*, deposit.*, order.* y fill.*.
Una orden (una por confirmación) se completa con 1..N fills. Una conversión normal
tiene exactamente un fill; una orden a crédito (capacity) se va completando con varios a
medida que se paga. Por eso fill.created puede llegar varias veces para un mismo orderId
— consulta Ids y correlación.
webhook.test no es suscribibleUn ping de prueba llega con test: true para que verifiques el cableado de tu endpoint.
No es un evento del catálogo y no puedes suscribirte a él: una prueba es algo que se
dispara, no algo que ocurre en tu cuenta. Todavía no hay endpoint público para dispararla —
v2 no expone gestión de webhooks, solo el JWKS de verificación en /.well-known.
Pueden aparecer eventos nuevos en cualquier momento. Un consumidor correcto descarta en
silencio un eventType que no reconoce, en lugar de fallar. Suscribirse con * implica
aceptar ese contrato.
Política de cambios
Pueden aparecer campos nuevos en data en cualquier momento. Trátalo como un objeto
extensible: parsea los campos que te importan e ignora el resto.
Los suppliers también son consumidores
Un supplier (la contraparte que ejecuta una conversión) es un consumidor de la API de primera clase, simétrico a cualquier otro: usa las mismas llaves, los mismos endpoints REST y los mismos webhooks, para su propia conciliación y automatización.
Lo que ve es su lado de la operación:
- sus propios retiros y depósitos;
- las órdenes y fills en los que él fue el supplier seleccionado.
Una conversión es bilateral: una misma ejecución produce un evento para el cliente y otro
para el supplier, cada uno con su perspectiva (el side está invertido). No es una
capacidad interna ni oculta — es el mismo contrato documentado en esta página.
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
El valor de la cabecera es un JWS en forma detached: el segmento del payload va vacío, así que lleva dos puntos seguidos sin nada entre ellos:
eyJhbGciOiJFZERTQSIsImtpZCI6IndoaS…<protected>..<signature>
El header protegido lleva el id de la clave y la marca de tiempo firmada:
{ "alg": "EdDSA", "kid": "whk_939492a5e222", "crit": ["skipo.io/iat"], "skipo.io/iat": 1786030000 }
Pasos de verificación:
- Parte la cabecera por
.en<protected>, un segmento vacío y<signature>. - Busca el JWK cuyo
kidcoincida con el del header protegido (cachea el JWKS). - Reconstruye la entrada de firma reinsertando el payload:
<protected> + "." + base64url(cuerpoCrudo). - Verifica la firma Ed25519 sobre esa entrada.
- Compara
skipo.io/iatcon tu reloj y rechaza cualquier cosa desviada más de 300 segundos (5 minutos) en cualquier dirección. Esto es lo que impide reproducir (replay) una entrega capturada.
Verifica siempre contra los bytes crudos del cuerpo, antes de parsear el JSON. Reserializar el JSON cambia los bytes y la firma dejará de validar, aunque el objeto parseado sea idéntico.
Node.js
Sin dependencias: node:crypto verifica Ed25519 y acepta el JWK directamente.
import crypto from 'node:crypto'
const MAX_SKEW_SECONDS = 300
function verifySkipoWebhook(rawBody, signatureHeader, jwks) {
const [protectedB64, empty, signatureB64] = signatureHeader.split('.')
if (empty !== '' || !protectedB64 || !signatureB64) throw new Error('malformed signature header')
const header = JSON.parse(Buffer.from(protectedB64, 'base64url').toString('utf8'))
if (header.alg !== 'EdDSA') throw new Error(`unexpected alg ${header.alg}`)
const jwk = jwks.keys.find((k) => k.kid === header.kid)
if (!jwk) throw new Error(`unknown kid ${header.kid}`)
// JWS detached: reinserta el payload en base64url para reconstruir la entrada de firma.
const signingInput = `${protectedB64}.${Buffer.from(rawBody).toString('base64url')}`
const key = crypto.createPublicKey({ key: jwk, format: 'jwk' })
const ok = crypto.verify(null, Buffer.from(signingInput), key, Buffer.from(signatureB64, 'base64url'))
if (!ok) throw new Error('bad signature')
const iat = header['skipo.io/iat']
if (typeof iat !== 'number') throw new Error('missing skipo.io/iat')
if (Math.abs(Math.floor(Date.now() / 1000) - iat) > MAX_SKEW_SECONDS) {
throw new Error('stale signature (replay?)')
}
return JSON.parse(Buffer.from(rawBody).toString('utf8'))
}
En Express, obtén los bytes crudos con express.raw({ type: 'application/json' }):
express.json() los parsea y los descarta, y entonces ya no se puede comprobar la firma.
Python
import base64, json, time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
MAX_SKEW_SECONDS = 300
def _b64u_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def _b64u_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def verify_skipo_webhook(raw_body: bytes, signature_header: str, jwks: dict) -> dict:
parts = signature_header.split(".")
if len(parts) != 3 or parts[1] != "":
raise ValueError("malformed signature header")
protected_b64, _, signature_b64 = parts
header = json.loads(_b64u_decode(protected_b64))
if header.get("alg") != "EdDSA":
raise ValueError(f"unexpected alg {header.get('alg')}")
jwk = next((k for k in jwks["keys"] if k.get("kid") == header.get("kid")), None)
if jwk is None:
raise ValueError(f"unknown kid {header.get('kid')}")
# JWS detached: reinserta el payload en base64url para reconstruir la entrada de firma.
signing_input = f"{protected_b64}.{_b64u_encode(raw_body)}".encode()
key = Ed25519PublicKey.from_public_bytes(_b64u_decode(jwk["x"]))
try:
key.verify(_b64u_decode(signature_b64), signing_input)
except InvalidSignature:
raise ValueError("bad signature")
iat = header.get("skipo.io/iat")
if not isinstance(iat, int):
raise ValueError("missing skipo.io/iat")
if abs(int(time.time()) - iat) > MAX_SKEW_SECONDS:
raise ValueError("stale signature (replay?)")
return json.loads(raw_body)
Rotación de claves
El kid existe para poder rotar la clave de firma sin romper tu integración. Selecciona el
JWK por el kid del header protegido en vez de asumir una sola clave, cachea el JWKS y
vuelve a pedirlo cuando veas un kid desconocido. No fijes el material de la clave.
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.
Las entregas del mismo recurso se serializan en la medida de lo posible, pero el orden
no está garantizado: usa createdAt y el status del recurso para decidir cuál es el
estado más reciente, en vez de asumir el orden de llegada.
Reenvíos
Un evento reenviado llega con el mismo id lógico, así que si deduplicas por él (como
debes) reenviar algo que ya procesaste no tiene efecto. Llega con un
skipo-webhook-delivery-id nuevo, y así distingues un reenvío del original.
Registrar, editar o suspender un endpoint de webhook se hace desde el panel de Skipo, no desde la API autenticada por llave. Es deliberado: una llave filtrada no debe poder redirigir tus notificaciones.