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. 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-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 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 conassetFormat. Si pidesassetFormat=basepor 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 enrebased.
Nombres de campo (los valores son los mismos):
- 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). |
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": { "…": "…" }
}
| Campo | Qué es |
|---|---|
asset, amount, fee, total | Nivel superior: siempre rebased. asset es el símbolo rebasado (COPXX), no el liquidado (COPXSPV). |
assetFormat | El literal "rebased". Siempre presente, para que nunca tengas que inferir en qué unidades te están hablando. |
multiplier | El factor vigente en el momento de ese movimiento, no el de hoy — ver Acciones tokenizadas. Es "1" fuera de los xStocks. |
base | Los valores liquidados (los tokens SPV): lo que se movió en cadena y en el libro contable. Siempre presente. |
transactionHash | El 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. |
bankReference | La 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.
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.
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:
baseAmountyfilledBaseAmountse multiplican por el multiplicador;quoteAmountyfilledQuoteAmountse quedan igual: el dinero que pagas no cambia porque cambie la unidad en la que cuentas las acciones;ratese 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"
}
| Campo | El nivel superior frente a base |
|---|---|
baseAsset | símbolo rebasado (NVDAX) frente al liquidado (NVDASPV) |
baseAmount, filledBaseAmount | base × multiplier |
quoteAsset, quoteAmount, filledQuoteAmount | idénticos: la pata de cotización no rebasa |
rate | base.rate ÷ multiplier |
market | no 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.
baseEl 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 (*).
| Evento | Cuándo | resourceId | Forma de data |
|---|---|---|---|
withdrawal.created | Se crea un retiro y se congela el saldo. | Id del retiro | Movimiento |
withdrawal.status.updated | El retiro cambia de estado, incluido el terminal. | Id del retiro | Movimiento |
deposit.created | Se detecta y registra un depósito. | Id del depósito | Movimiento |
deposit.status.updated | El depósito cambia de estado, incluido el terminal. | Id del depósito | Movimiento |
order.created | Se coloca una orden de conversión (al confirmar), con status: "NEW". | clOrdId | Orden |
order.status.updated | La orden cambia de estado. | clOrdId | Orden |
fill.created | Se ejecuta un fill contra una orden. | Id del fill | Fill |
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:
| Recurso | Valores | Terminales |
|---|---|---|
| Retiro y depósito | PENDING, IN_PROGRESS, COMPLETED, FAILED | COMPLETED, FAILED |
| Orden | NEW, PARTIALLY_FILLED, FILLED, FAILED | FILLED, 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.
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 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.
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.
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"
}
| Campo | Qué es |
|---|---|
id | El clOrdId: el id público y estable de la orden. Es a lo que apunta el orderId de cada fill. |
status | NEW, PARTIALLY_FILLED, FILLED o FAILED. |
side | BUY o SELL, desde tu perspectiva. En el evento del supplier viene invertido. |
market | El par, BASE-QUOTE. No se rebasa: identifica un instrumento, no una cantidad. |
baseAmount / quoteAmount | Lo pedido en cada pata. |
filledBaseAmount / filledQuoteAmount | Lo ejecutado hasta ahora. En FILLED igualan a los pedidos. |
rate | Precio de la orden. Ojo: se rebasa al revés — ver abajo. |
onCredit | true 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"
}
| Campo | Qué es |
|---|---|
id | Id del fill. |
orderId | El clOrdId de la orden padre. Puede venir null — no asumas que siempre podrás enlazar. |
baseAmount / quoteAmount | Lo ejecutado en este fill, no el acumulado de la orden. |
executedAt | Cuá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:
| Escenario | Eventos, en orden causal |
|---|---|
| Retiro cripto normal | withdrawal.created (PENDING) → withdrawal.status.updated (COMPLETED, ya con transactionHash) |
| Retiro que falla antes de emitirse | withdrawal.created (PENDING) → withdrawal.status.updated (FAILED, transactionHash: null) |
| Depósito cripto | deposit.created → deposit.status.updated (COMPLETED) |
| Conversión simple | order.created (NEW) → fill.created → order.status.updated (FILLED) |
| Conversión a crédito | order.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.
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:
- 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.