Skip to main content

Webhooks

Instead of polling, you can register a URL to receive webhooks when your transactions change. Every delivery is signed so you can verify it came from Skipo and was not tampered with.

Event envelope

Every webhook has a stable envelope modeled on Fireblocks v2:

{
"id": "evt_01H…",
"webhookId": "whk_01H…",
"eventType": "fill.created",
"resourceId": "1096473",
"createdAt": 1783003522123,
"data": {
"id": "1096473",
"orderId": "clord_01H…",
"side": "BUY"
}
}
FieldTypeDescription
idstringLogical event id. This is your idempotency key.
webhookIdstringThe endpoint the delivery went to.
eventTypestringOne of the catalog.
resourceIdstring | nullId of the affected resource.
createdAtnumberWhen the event happened, in milliseconds since epoch.
dataobjectSnapshot of the affected resource. It does not match the REST representation of that same resource — neither in field names nor in the units of the amounts (see below).
createdAt is a number, not an ISO-8601 string

The envelope uses millisecond epoch (1783003522123), not "2026-07-10T12:34:56.000Z". Earlier versions of this page showed ISO-8601: the documentation was wrong, not the payload. If your integration parses that field as a string, fix it.

The asymmetry is deliberate. The envelope is produced by the webhooks platform and uses epoch — an integer, with no timezone or format ambiguity. The timestamps inside data (createdAt, executedAt) are ISO-8601, because they are the exact same values the REST API returns. Rule of thumb: outside data, epoch; inside data, ISO-8601.

data is not the REST response

data is a snapshot of the affected resource, but it is not guaranteed to match the REST representation of that same resource. The divergences come in two kinds — and the first is the one that actually breaks reconciliations, because it does not change a field's name but an amount's units:

Representation (the values differ):

  • the top level of the payload is always rebased, whereas a REST response is in whatever representation you asked for with assetFormat. If you request assetFormat=base over REST and compare it against the webhook for the same movement, the amounts will not match — they are expressed in different units, and neither one is wrong. See Amounts are always rebased.

Field names (the values are the same):

  • the webhook payload emits asset where the REST resource publishes assetSymbol;
  • it emits a composite assetId (for example "USDT-TRON") where the REST resource publishes a bare networkSymbol.

A fill payload also omits fields the REST fill resource carries. Code against the webhook payload itself — do not reuse your REST response parser.

Idempotency and resends

id is the logical event id and is stable across resends: if Skipo retries the delivery, or the event is resent, the same id arrives. Deduplicate on it and a money-movement webhook will never be processed twice.

Every delivery also carries these headers:

HeaderContents
skipo-webhook-delivery-idId of this delivery. It is the same across all automatic retries of one delivery; only a resend gets a different one.
skipo-webhook-eventThe eventType, so you can route without parsing the body.
skipo-webhook-signatureThe detached JWS signature (see below).

Amounts are always rebased

A webhook does not accept assetFormat, and cannot: Skipo initiates the delivery, not you, so there is no query string in which to ask for a representation. The representation is therefore fixed, and we fix it to the same one REST returns by default — the top level of the payload is always rebased, the underlying equity, carrying the rebased symbol.

So that you can reconcile without a second call, all seven events in the catalog carry both representations at once: the top level in rebased, and a base block holding the settled figures.

Start with a movement (withdrawal.* and deposit.*). Here is its data object (the id in here is the movement's, not the event's — that one lives on the envelope):

{
"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": { "…": "…" }
}
FieldWhat it is
asset, amount, fee, totalTop level: always rebased. asset is the rebased symbol (COPXX), not the settled one (COPXSPV).
assetFormatThe literal "rebased". Always present, so you never have to infer which units you are being told about.
multiplierThe factor in effect at the time of that movement, not today's — see Tokenized equities. It is "1" outside xStocks.
baseThe settled values (the SPV tokens): what moved on chain and in the ledger. Always present.
transactionHashThe on-chain hash of the movement, when there was one. null on a movement that never touched a chain (internal transfer, bank payout), and null yet on one that has not been broadcast — the hash arrives on a later event. Do not assume a 0x prefix: BTC, Solana and Tron hashes have none.
bankReferenceThe bank's or payment provider's own reference, when one was recorded. null on every non-fiat movement. The counterpart of transactionHash: a movement settles either on a chain or through a bank, so at most one of the two is ever populated. Short and free-form (1-19 characters, not always numeric) — treat it as an opaque string for reconciliation, never parse it.

On a movement, field by field, base.amount × multiplier = amount, and likewise for fee and total: all three figures are denominated in the same asset, so they all scale alike. A deposit.* payload is identical except that the trailing block is called depositData.

On an order or a fill that field-by-field relation does not hold — and that is not an oversight, it is what has to happen. It gets its own section: an order rebases only one of its two legs.

The shape does not vary by asset

For a non-xStock asset (USDT, say), multiplier is "1", asset equals base.asset, and the base block mirrors the top level exactly. The assetFormat and multiplier literals and the base block never disappear: you can parse one shape for every asset, with no branching on asset type.

This breaks the previous behaviour

Until now the top level carried the settled figures with the SPV symbol ("asset": "COPXSPV"). It now carries the rebased figures with the rebased symbol ("asset": "COPXX"). If your integration reconciles against on-chain balances, read the base block: it holds exactly the values you used to receive at the top level.

An order rebases only one of its two legs

An order has two legs, and the multiplier only touches one of them. The base leg is denominated in the token; the quote leg (quoteAsset — CLP, USDT…) knows nothing about tokenized equities. So when rebasing:

  • baseAmount and filledBaseAmount are multiplied by the multiplier;
  • quoteAmount and filledQuoteAmount stay the same: the money you pay does not change because the unit you count shares in changed;
  • rate is divided, because it is a price per unit of the base leg, and that unit just got smaller.

That division is exactly what preserves the notional: baseAmount × rate = quoteAmount holds in both representations. It is also why base is not "the top level divided by the multiplier" — compute it that way and your CLP figure comes out wrong.

{
"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"
}
FieldTop level vs base
baseAssetthe rebased symbol (NVDAX) vs the settled one (NVDASPV)
baseAmount, filledBaseAmountbase × multiplier
quoteAsset, quoteAmount, filledQuoteAmountidentical: the quote leg does not rebase
ratebase.rate ÷ multiplier
marketnever rebases. It is the pair identifier, and REST spells it the same way (NVDASPV-CLP), so it works as-is for re-fetching.

A fill.created carries the same base block in the smaller shape a fill emits: baseAsset, quoteAsset, baseAmount, quoteAmount and rate. A fill has no filled… figures — the fill is the execution.

Reconcile against base

The base block comes out exactly as stored: we read it, we do not derive it by dividing. So it is exact. The top level is a product — and for rate, a quotient — rounded to 20 significant figures, so on an xStock baseAmount × rate can drift from quoteAmount by one unit in the last decimal. If you reconcile to the cent, use base and the multiplier.

Event catalog

There are 7 events. Types use dot notation and support wildcards when subscribing: exact (withdrawal.created), by category (withdrawal.*), or global (*).

EventWhenresourceIddata shape
withdrawal.createdA withdrawal is created and the balance frozen.Withdrawal idMovement
withdrawal.status.updatedThe withdrawal changes status, terminal included.Withdrawal idMovement
deposit.createdA deposit is detected and booked.Deposit idMovement
deposit.status.updatedThe deposit changes status, terminal included.Deposit idMovement
order.createdA convert order is placed (at confirm), with status: "NEW".clOrdIdOrder
order.status.updatedThe order changes status.clOrdIdOrder
fill.createdA fill executes against an order.Fill idFill

The subscribable categories are exactly withdrawal.*, deposit.*, order.* and fill.*.

The status values you can receive

status travels inside data. These are every possible value, per resource:

ResourceValuesTerminal
Withdrawal and depositPENDING, IN_PROGRESS, COMPLETED, FAILEDCOMPLETED, FAILED
OrderNEW, PARTIALLY_FILLED, FILLED, FAILEDFILLED, FAILED
Fill— a fill has no status: existing is having executed

Order statuses are the exchange-standard ones, not the internal ones: converts-service stores PENDING/STARTED/IN_PROGRESS/COMPLETED internally and translates them to NEW/PARTIALLY_FILLED/PARTIALLY_FILLED/FILLED before publishing. GET /v2/orders applies the same translation, so the event and the REST resource agree.

The status channel carries transitions, not a complete log

A *.status.updated event tells you the resource is now in that status. It does not guarantee one event per every internal transition: a movement can pass through intermediate states without emitting anything, and a withdrawal that is born and finishes in one operation may give you only the terminal event.

The practical consequence: do not reconstruct history from the event sequence. Treat each payload as the current state and keep the last one you saw for that resourceId. If you need the history, read it over REST.

A resource can be born terminal

A deposit detected already confirmed, or an instant internal withdrawal, produces .created and .status.updated with the same status: "COMPLETED" — and sometimes with the same millisecond in createdAt. It is not a duplicate: they are two events with different id. If your logic assumes .created always arrives in a non-terminal state, revisit it.

Orders and fills

An order (one per confirmation) is completed by 1..N fills. A normal conversion has exactly one fill; an on-credit (capacity) order is filled by several as the debt is paid down. That is why fill.created can arrive more than once for the same orderId — see Ids and correlation.

webhook.test is not subscribable

A test ping arrives with test: true so you can verify your endpoint wiring. It is not a catalog event and you cannot subscribe to it: a test is something you trigger, not something that happens in your account. There is no public endpoint to trigger it yet — v2 exposes no webhook management, only the verification JWKS at /.well-known.

Ignore types you do not know

New events may appear at any time. A correct consumer silently discards an eventType it does not recognize rather than failing. Subscribing with * means accepting that contract.

The shape of data, per resource

There are three data shapes, one per resource type. All three share the same representation contract (assetFormat, multiplier and a base block, always present), so you can write a single amount reader for all of them.

Movement (withdrawal.* and deposit.*)

This is the payload shown above. The trailing block is called withdrawalData on a withdrawal and depositData on a deposit, and its contents depend on the subType: only the on-chain and RedPay sub-types carry detail; the rest send {}.

transactionHash and bankReference live at the top level, so you never have to branch on subType to read them.

Order (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"
}
FieldWhat it is
idThe clOrdId: the stable public order id. It is what each fill's orderId points at.
statusNEW, PARTIALLY_FILLED, FILLED or FAILED.
sideBUY or SELL, from your perspective. It is inverted in the supplier's event.
marketThe pair, BASE-QUOTE. Never rebased: it identifies an instrument, not a quantity.
baseAmount / quoteAmountWhat was requested on each leg.
filledBaseAmount / filledQuoteAmountWhat has executed so far. On FILLED they equal the requested amounts.
rateThe order's price. Note it rebases inversely — see below.
onCredittrue for an on-credit (capacity) order, which is filled by several 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"
}
FieldWhat it is
idThe fill id.
orderIdThe parent order's clOrdId. It can be null — do not assume you can always link.
baseAmount / quoteAmountWhat executed in this fill, not the order's running total.
executedAtWhen it executed, ISO-8601. A fill has no createdAt.

A fill carries no status: if it reached you, it executed. The order's running total lives in the order payload's filledBaseAmount / filledQuoteAmount, not in the fill's.

Typical sequences

None of these is guaranteed field by field — they are the shape the lifecycle takes in practice, useful for knowing what to expect:

ScenarioEvents, in causal order
Normal crypto withdrawalwithdrawal.created (PENDING) → withdrawal.status.updated (COMPLETED, with transactionHash by then)
Withdrawal that fails before broadcastwithdrawal.created (PENDING) → withdrawal.status.updated (FAILED, transactionHash: null)
Crypto depositdeposit.createddeposit.status.updated (COMPLETED)
Simple conversionorder.created (NEW) → fill.createdorder.status.updated (FILLED)
On-credit conversionorder.created (NEW) → fill.created × N, interleaved with order.status.updated (PARTIALLY_FILLED) → order.status.updated (FILLED)

Delivery order

Deliveries for the same resource to the same endpoint are causally ordered: they are grouped by (endpoint, resourceId) and delivered serially, by occurredAt, breaking ties with .created first. So an order.status.updated can never overtake its own order.created, not even when the source stamps both in the same millisecond.

A fill is not ordered against its order

That grouping is by resourceId, and an order and its fills have different resourceIds — so they land in different groups and can be delivered in any order relative to each other. It is entirely possible to receive a fill.created before the order.created of its own order.

Do not treat an orphan fill as an error: store it by orderId and reconcile when the order arrives, or read the order over REST. The same applies across different resources: there is no ordering at all between a withdrawal and a conversion.

Change policy

New fields can appear in data at any time. Treat it as an extensible object: parse the fields you care about and ignore the rest.

Suppliers are consumers too

A supplier — the counterparty that executes a conversion — is a first-class API consumer, symmetric with any other: same keys, same REST endpoints, same webhooks, for its own reconciliation and automation.

What it sees is its own side:

  • its own withdrawals and deposits;
  • the orders and fills for which it was the selected supplier.

A conversion is bilateral: one execution produces an event for the customer and another for the supplier, each from its perspective (side is flipped). This is not an internal or hidden capability — it is the same contract documented on this page.

Verifying the signature

Every delivery includes a detached JWS signature (EdDSA/Ed25519) in the skipo-webhook-signature header, with a signed timestamp in the critical header skipo.io/iat. Verify it against the public key published in the JWKS:

GET https://api.skipo.com/v2/.well-known/webhook-jwks.json

The header value is a JWS in detached form — the payload segment is empty, so it has two dots with nothing between them:

eyJhbGciOiJFZERTQSIsImtpZCI6IndoaS…<protected>..<signature>

The protected header carries the key id and the signed timestamp:

{ "alg": "EdDSA", "kid": "whk_939492a5e222", "crit": ["skipo.io/iat"], "skipo.io/iat": 1786030000 }

Verification steps:

  1. Split the header on . into <protected>, an empty segment, and <signature>.
  2. Look up the JWK whose kid matches the protected header's (cache the JWKS).
  3. Rebuild the signing input by re-inserting the payload: <protected> + "." + base64url(rawBody).
  4. Verify the Ed25519 signature over that input.
  5. Check skipo.io/iat against your clock — reject anything more than 300 seconds (5 minutes) out, in either direction. This is what stops a captured delivery being replayed.
warning

Always verify against the raw bytes of the body, before parsing the JSON. Re-serializing the JSON changes the bytes and the signature will not validate — even when the parsed object is identical.

Node.js

No dependencies — node:crypto verifies Ed25519 and accepts the JWK directly.

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}`)

// Detached JWS: re-insert the base64url payload to rebuild the signing input.
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'))
}

In Express, get the raw bytes with express.raw({ type: 'application/json' })express.json() parses and discards them, and the signature can no longer be checked.

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')}")

# Detached JWS: re-insert the base64url payload to rebuild the signing input.
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)

Key rotation

kid exists so the signing key can be rotated without breaking you. Select the JWK by the kid in the protected header rather than assuming a single key, cache the JWKS, and re-fetch it when you see an unknown kid. Do not pin the key material itself.

Retries

If your endpoint does not respond 2xx, Skipo retries on a backoff schedule. An endpoint that fails persistently is automatically suspended, and you can re-enable it from the dashboard.

Deliveries for the same resource are serialized on a best-effort basis, but ordering is not guaranteed: use createdAt and the resource's own status to decide which state is most recent, rather than assuming arrival order.

Resends

A resent event carries the same logical id, so if you deduplicate on it (as you should) a resend of something you already processed is a no-op. It arrives with a new skipo-webhook-delivery-id, which is how you tell a redelivery from the original.

Endpoint management lives in the dashboard

Registering, editing or suspending a webhook endpoint is done from the Skipo dashboard, not from the key-authenticated API. This is deliberate: a leaked key must not be able to redirect your notifications.