Saltar al contenido principal

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"
}
}
CampoTipoDescripción
idstringId lógico del evento. Es tu clave de idempotencia.
webhookIdstringEl endpoint que recibió la entrega.
eventTypestringUno del catálogo.
resourceIdstring | nullId del recurso afectado.
createdAtnumberInstante del evento, en milisegundos desde epoch.
dataobjectSnapshot del recurso afectado. No es idéntico a la representación REST del mismo recurso: ni en los nombres de los campos ni en las unidades de los importes (ver abajo).
createdAt es un número, no una cadena ISO-8601

La 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 REST

data es un snapshot del recurso afectado, pero no garantizamos que coincida con la representación REST de ese mismo recurso. Las divergencias son de dos clases —y la primera es la que de verdad rompe conciliaciones, porque no cambia el nombre de un campo sino las unidades de un importe:

Representación (los valores difieren):

  • el nivel superior del payload va siempre en rebased, mientras que una respuesta REST va en la representación que hayas pedido con assetFormat. Si pides assetFormat=base por REST y lo comparas contra el webhook del mismo movimiento, los importes no van a cuadrar: están expresados en unidades distintas y ninguno de los dos está mal. Ver Los importes van siempre en rebased.

Nombres de campo (los valores son los mismos):

  • el payload del webhook emite asset donde el recurso REST publica assetSymbol;
  • emite un assetId compuesto (por ejemplo "USDT-TRON") donde el recurso REST publica un networkSymbol suelto.

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:

CabeceraContenido
skipo-webhook-delivery-idId de esta entrega. Es el mismo en todos los reintentos automáticos de una entrega; solo cambia en un reenvío.
skipo-webhook-eventEl eventType, para enrutar sin parsear el cuerpo.
skipo-webhook-signatureLa firma JWS detached (ver abajo).

Los importes van siempre en rebased

Un webhook no acepta assetFormat, y no puede aceptarlo: la entrega la inicia Skipo, no tú, así que no hay query string donde pedir una representación. La representación es por tanto fija, y la fijamos en la misma que devuelve REST por defecto: el nivel superior del payload va siempre en rebased —la acción subyacente—, con el símbolo rebasado.

Para que puedas conciliar sin una segunda llamada, los siete eventos del catálogo llevan las dos representaciones a la vez: el nivel superior en rebased, y un bloque base con las cifras liquidadas.

Empecemos por un movimiento (withdrawal.* y deposit.*). Así queda su objeto data (el id de aquí dentro es el del movimiento, no el del evento, que va en la envoltura):

{
"id": "…",
"type": "WITHDRAWAL",
"subType": "INTERNAL",
"asset": "COPXX",
"amount": "0.18929052835346211008",
"fee": "0",
"total": "0.18929052835346211008",
"status": "COMPLETED",
"createdAt": "2026-08-14T18:51:31.066Z",
"transactionHash": "0x8b3275b467d90d99bd99bb7bfec9402e393ce870d741131a9d69a7d42640ac5b",
"bankReference": null,
"assetFormat": "rebased",
"multiplier": "1.002370480441",
"base": {
"asset": "COPXSPV",
"amount": "0.18884288",
"fee": "0",
"total": "0.18884288"
},
"withdrawalData": { "…": "…" }
}
CampoQué es
asset, amount, fee, totalNivel superior: siempre rebased. asset es el símbolo rebasado (COPXX), no el liquidado (COPXSPV).
assetFormatEl literal "rebased". Siempre presente, para que nunca tengas que inferir en qué unidades te están hablando.
multiplierEl factor vigente en el momento de ese movimiento, no el de hoy — ver Acciones tokenizadas. Es "1" fuera de los xStocks.
baseLos valores liquidados (los tokens SPV): lo que se movió en cadena y en el libro contable. Siempre presente.
transactionHashEl hash on-chain del movimiento, cuando lo hubo. null en un movimiento que no pasó por una cadena (transferencia interna, pago por banco), y null todavía en uno que aún no se ha emitido — el hash aparece en un evento posterior. No supongas el prefijo 0x: los hashes de BTC, Solana y Tron no lo llevan.
bankReferenceLa referencia del banco o del proveedor de pago, cuando quedó registrada. null en todo movimiento que no sea fiat. Es el reverso de transactionHash: un movimiento se liquida en una cadena o por un banco, así que como mucho uno de los dos viene poblado. Cadena corta y libre (1-19 caracteres, no siempre numérica) — trátala como identificador opaco para conciliar, nunca la parsees.

En un movimiento se cumple, campo a campo, que base.amount × multiplier = amount, y lo mismo para fee y total: las tres cifras están denominadas en el mismo activo, así que todas escalan igual. Un payload de deposit.* es idéntico salvo que el bloque final se llama depositData.

En una orden o un fill esa relación campo a campo no se cumple, y no es un descuido — es lo que tiene que pasar. Tiene sección propia: una orden rebasa solo una de sus dos patas.

La forma no cambia según el activo

En un activo que no es un xStock (por ejemplo USDT), multiplier vale "1", asset es igual a base.asset, y el bloque base refleja el nivel superior exactamente. Los literales assetFormat y multiplier y el bloque base no desaparecen nunca: puedes parsear una sola forma para todos los activos, sin ramificar por tipo de activo.

Esto rompe el comportamiento anterior

Hasta ahora el nivel superior llevaba las cifras liquidadas con el símbolo SPV ("asset": "COPXSPV"). Ahora lleva las rebasadas con el símbolo rebasado ("asset": "COPXX"). Si tu integración concilia contra saldos en cadena, lee el bloque base: contiene exactamente los valores que antes recibías en el nivel superior.

Una orden rebasa solo una de sus dos patas

Una orden tiene dos patas, y el multiplicador solo afecta a una. La pata base está denominada en el token; la de cotización (quoteAsset — CLP, USDT…) no sabe nada de acciones tokenizadas. Por eso, al rebasar:

  • baseAmount y filledBaseAmount se multiplican por el multiplicador;
  • quoteAmount y filledQuoteAmount se quedan igual: el dinero que pagas no cambia porque cambie la unidad en la que cuentas las acciones;
  • rate se divide, porque es un precio por unidad de la pata base, y esa unidad acaba de hacerse más pequeña.

Esa división es justo lo que conserva el nocional: baseAmount × rate = quoteAmount se cumple en las dos representaciones. Y es el motivo de que base no sea "el nivel superior dividido por el multiplicador": si lo calculas así, el importe en CLP te sale mal.

{
"id": "…",
"status": "FILLED",
"side": "BUY",
"market": "NVDASPV-CLP",
"baseAsset": "NVDAX",
"quoteAsset": "CLP",
"baseAmount": "2.5059262011025",
"filledBaseAmount": "2.5059262011025",
"quoteAmount": "50000",
"filledQuoteAmount": "50000",
"rate": "19952.702508957424797",
"multiplier": "1.002370480441",
"assetFormat": "rebased",
"base": {
"baseAsset": "NVDASPV",
"quoteAsset": "CLP",
"baseAmount": "2.5",
"filledBaseAmount": "2.5",
"quoteAmount": "50000",
"filledQuoteAmount": "50000",
"rate": "20000"
},
"onCredit": false,
"createdAt": "2026-08-14T18:51:31.066Z"
}
CampoEl nivel superior frente a base
baseAssetsímbolo rebasado (NVDAX) frente al liquidado (NVDASPV)
baseAmount, filledBaseAmountbase × multiplier
quoteAsset, quoteAmount, filledQuoteAmountidénticos: la pata de cotización no rebasa
ratebase.rate ÷ multiplier
marketno rebasa nunca. Es el identificador del par, y REST lo escribe igual (NVDASPV-CLP), así que sirve tal cual para volver a consultar.

Un fill.created lleva el mismo bloque base con la forma más pequeña que emite un fill: baseAsset, quoteAsset, baseAmount, quoteAmount y rate. Un fill no tiene cifras filled… — el fill es la ejecución.

Concilia contra base

El bloque base sale tal como está almacenado: no lo calculamos dividiendo, lo leemos. Es exacto. El nivel superior es un producto —y en rate, un cociente— redondeado a 20 cifras significativas, así que en un xStock baseAmount × rate puede separarse de quoteAmount en una unidad del último decimal. Si concilias al céntimo, usa base y el multiplicador.

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 (*).

EventoCuándoresourceIdForma de data
withdrawal.createdSe crea un retiro y se congela el saldo.Id del retiroMovimiento
withdrawal.status.updatedEl retiro cambia de estado, incluido el terminal.Id del retiroMovimiento
deposit.createdSe detecta y registra un depósito.Id del depósitoMovimiento
deposit.status.updatedEl depósito cambia de estado, incluido el terminal.Id del depósitoMovimiento
order.createdSe coloca una orden de conversión (al confirmar), con status: "NEW".clOrdIdOrden
order.status.updatedLa orden cambia de estado.clOrdIdOrden
fill.createdSe ejecuta un fill contra una orden.Id del fillFill

Las categorías suscribibles son exactamente withdrawal.*, deposit.*, order.* y fill.*.

Valores de status que puedes recibir

status viaja dentro de data. Estos son todos los valores posibles, por recurso:

RecursoValoresTerminales
Retiro y depósitoPENDING, IN_PROGRESS, COMPLETED, FAILEDCOMPLETED, FAILED
OrdenNEW, PARTIALLY_FILLED, FILLED, FAILEDFILLED, FAILED
Fill— un fill no tiene estado: existir es haberse ejecutado

Los estados de la orden son los estándar de exchange, no los internos: por dentro converts-service guarda PENDING/STARTED/IN_PROGRESS/COMPLETED, y se traducen a NEW/PARTIALLY_FILLED/PARTIALLY_FILLED/FILLED antes de publicarse. La misma traducción la aplica GET /v2/orders, así que el evento y el recurso REST coinciden.

El canal de estado lleva transiciones, no una bitácora completa

Un evento *.status.updated te dice que el recurso está ahora en ese estado. No garantiza que recibas un evento por cada transición interna: un movimiento puede pasar por estados intermedios sin emitir nada, y un retiro que nace y termina en la misma operación puede darte solo el terminal.

La consecuencia práctica: no reconstruyas la historia a partir de la secuencia de eventos. Trata cada payload como el estado actual y quédate con el último que hayas visto para ese resourceId. Si necesitas la historia, léela por REST.

Un recurso puede nacer terminal

Un depósito detectado ya confirmado, o un retiro interno instantáneo, produce .created y .status.updated con el mismo status: "COMPLETED" — y a veces con el mismo milisegundo en createdAt. No es un duplicado: son dos eventos con id distinto. Si tu lógica asume que .created siempre llega en un estado no terminal, revísala.

Órdenes y fills

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 suscribible

Un 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.

Ignora los tipos que no conozcas

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.

Estructura de data por recurso

Hay tres formas de data, una por tipo de recurso. Las tres comparten el mismo contrato de representación (assetFormat, multiplier y bloque base siempre presentes), así que puedes escribir un solo lector de importes para las tres.

Movimiento (withdrawal.* y deposit.*)

Es el payload que se muestra arriba. El bloque final se llama withdrawalData en un retiro y depositData en un depósito, y su contenido depende del subType: solo los sub-tipos on-chain y los de RedPay llevan detalle; el resto trae {}.

transactionHash y bankReference viven en el nivel superior, así que no tienes que ramificar por subType para leerlos.

Orden (order.*)

{
"id": "clord_01H…",
"status": "PARTIALLY_FILLED",
"side": "BUY",
"market": "COPXX-USDT",
"baseAsset": "COPXX",
"quoteAsset": "USDT",
"baseAmount": "10.023704804410",
"filledBaseAmount": "4.009481921764",
"quoteAmount": "250.00",
"filledQuoteAmount": "100.00",
"rate": "24.94",
"multiplier": "1.002370480441",
"assetFormat": "rebased",
"base": {
"baseAsset": "COPXSPV",
"quoteAsset": "USDT",
"baseAmount": "10",
"filledBaseAmount": "4",
"quoteAmount": "250.00",
"filledQuoteAmount": "100.00",
"rate": "25.00"
},
"onCredit": true,
"createdAt": "2026-08-14T18:51:31.066Z"
}
CampoQué es
idEl clOrdId: el id público y estable de la orden. Es a lo que apunta el orderId de cada fill.
statusNEW, PARTIALLY_FILLED, FILLED o FAILED.
sideBUY o SELL, desde tu perspectiva. En el evento del supplier viene invertido.
marketEl par, BASE-QUOTE. No se rebasa: identifica un instrumento, no una cantidad.
baseAmount / quoteAmountLo pedido en cada pata.
filledBaseAmount / filledQuoteAmountLo ejecutado hasta ahora. En FILLED igualan a los pedidos.
ratePrecio de la orden. Ojo: se rebasa al revés — ver abajo.
onCredittrue si es una orden a crédito (capacity), que se completa con varios fills.

Fill (fill.*)

{
"id": "1096473",
"orderId": "clord_01H…",
"side": "BUY",
"baseAsset": "COPXX",
"quoteAsset": "USDT",
"baseAmount": "4.009481921764",
"quoteAmount": "100.00",
"rate": "24.94",
"multiplier": "1.002370480441",
"assetFormat": "rebased",
"base": {
"baseAsset": "COPXSPV",
"quoteAsset": "USDT",
"baseAmount": "4",
"quoteAmount": "100.00",
"rate": "25.00"
},
"executedAt": "2026-08-14T18:51:33.212Z"
}
CampoQué es
idId del fill.
orderIdEl clOrdId de la orden padre. Puede venir null — no asumas que siempre podrás enlazar.
baseAmount / quoteAmountLo ejecutado en este fill, no el acumulado de la orden.
executedAtCuándo se ejecutó, en ISO-8601. Un fill no tiene createdAt.

Un fill no lleva status: si te llegó, se ejecutó. El acumulado de la orden vive en filledBaseAmount / filledQuoteAmount del payload de la orden, no en el del fill.

Secuencias típicas

Ninguna de estas secuencias está garantizada campo a campo — son la forma que toma el ciclo de vida en la práctica, útil para entender qué esperar:

EscenarioEventos, en orden causal
Retiro cripto normalwithdrawal.created (PENDING) → withdrawal.status.updated (COMPLETED, ya con transactionHash)
Retiro que falla antes de emitirsewithdrawal.created (PENDING) → withdrawal.status.updated (FAILED, transactionHash: null)
Depósito criptodeposit.createddeposit.status.updated (COMPLETED)
Conversión simpleorder.created (NEW) → fill.createdorder.status.updated (FILLED)
Conversión a créditoorder.created (NEW) → fill.created × N, intercalados con order.status.updated (PARTIALLY_FILLED) → order.status.updated (FILLED)

Orden de entrega

Las entregas de un mismo recurso a un mismo endpoint se ordenan causalmente: se agrupan por (endpoint, resourceId) y se entregan en serie, por occurredAt y desempatando .created primero. Así un order.status.updated no puede adelantar a su propio order.created, ni siquiera cuando el origen los sella en el mismo milisegundo.

Un fill no está ordenado contra su orden

Ese agrupamiento es por resourceId, y una orden y sus fills tienen resourceId distintos — así que van en grupos distintos y pueden entregarse en cualquier orden entre sí. Es perfectamente posible recibir un fill.created antes que el order.created de su propia orden.

No trates un fill huérfano como un error: guárdalo por orderId y reconcilia cuando llegue la orden, o léela por REST. Lo mismo aplica entre recursos distintos: no hay ningún orden entre un retiro y una conversión.

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:

  1. Parte la cabecera por . en <protected>, un segmento vacío y <signature>.
  2. Busca el JWK cuyo kid coincida con el del header protegido (cachea el JWKS).
  3. Reconstruye la entrada de firma reinsertando el payload: <protected> + "." + base64url(cuerpoCrudo).
  4. Verifica la firma Ed25519 sobre esa entrada.
  5. Compara skipo.io/iat con 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.
aviso

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.

La gestión de endpoints vive en el panel

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.