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"
}
}
| Field | Type | Description |
|---|---|---|
id | string | Logical event id. This is your idempotency key. |
webhookId | string | The endpoint the delivery went to. |
eventType | string | One of the catalog. |
resourceId | string | null | Id of the affected resource. |
createdAt | number | When the event happened, in milliseconds since epoch. |
data | object | Snapshot 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 stringThe 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 responsedata 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 withassetFormat. If you requestassetFormat=baseover 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
assetwhere the REST resource publishesassetSymbol; - it emits a composite
assetId(for example"USDT-TRON") where the REST resource publishes a barenetworkSymbol.
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:
| Header | Contents |
|---|---|
skipo-webhook-delivery-id | Id of this delivery. It is the same across all automatic retries of one delivery; only a resend gets a different one. |
skipo-webhook-event | The eventType, so you can route without parsing the body. |
skipo-webhook-signature | The 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": { "…": "…" }
}
| Field | What it is |
|---|---|
asset, amount, fee, total | Top level: always rebased. asset is the rebased symbol (COPXX), not the settled one (COPXSPV). |
assetFormat | The literal "rebased". Always present, so you never have to infer which units you are being told about. |
multiplier | The factor in effect at the time of that movement, not today's — see Tokenized equities. It is "1" outside xStocks. |
base | The settled values (the SPV tokens): what moved on chain and in the ledger. Always present. |
transactionHash | The 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. |
bankReference | The 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.
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.
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:
baseAmountandfilledBaseAmountare multiplied by the multiplier;quoteAmountandfilledQuoteAmountstay the same: the money you pay does not change because the unit you count shares in changed;rateis 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"
}
| Field | Top level vs base |
|---|---|
baseAsset | the rebased symbol (NVDAX) vs the settled one (NVDASPV) |
baseAmount, filledBaseAmount | base × multiplier |
quoteAsset, quoteAmount, filledQuoteAmount | identical: the quote leg does not rebase |
rate | base.rate ÷ multiplier |
market | never 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.
baseThe 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 (*).
| Event | When | resourceId | data shape |
|---|---|---|---|
withdrawal.created | A withdrawal is created and the balance frozen. | Withdrawal id | Movement |
withdrawal.status.updated | The withdrawal changes status, terminal included. | Withdrawal id | Movement |
deposit.created | A deposit is detected and booked. | Deposit id | Movement |
deposit.status.updated | The deposit changes status, terminal included. | Deposit id | Movement |
order.created | A convert order is placed (at confirm), with status: "NEW". | clOrdId | Order |
order.status.updated | The order changes status. | clOrdId | Order |
fill.created | A fill executes against an order. | Fill id | Fill |
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:
| Resource | Values | Terminal |
|---|---|---|
| Withdrawal and deposit | PENDING, IN_PROGRESS, COMPLETED, FAILED | COMPLETED, FAILED |
| Order | NEW, PARTIALLY_FILLED, FILLED, FAILED | FILLED, 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.
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 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.
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 subscribableA 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.
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"
}
| Field | What it is |
|---|---|
id | The clOrdId: the stable public order id. It is what each fill's orderId points at. |
status | NEW, PARTIALLY_FILLED, FILLED or FAILED. |
side | BUY or SELL, from your perspective. It is inverted in the supplier's event. |
market | The pair, BASE-QUOTE. Never rebased: it identifies an instrument, not a quantity. |
baseAmount / quoteAmount | What was requested on each leg. |
filledBaseAmount / filledQuoteAmount | What has executed so far. On FILLED they equal the requested amounts. |
rate | The order's price. Note it rebases inversely — see below. |
onCredit | true 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"
}
| Field | What it is |
|---|---|
id | The fill id. |
orderId | The parent order's clOrdId. It can be null — do not assume you can always link. |
baseAmount / quoteAmount | What executed in this fill, not the order's running total. |
executedAt | When 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:
| Scenario | Events, in causal order |
|---|---|
| Normal crypto withdrawal | withdrawal.created (PENDING) → withdrawal.status.updated (COMPLETED, with transactionHash by then) |
| Withdrawal that fails before broadcast | withdrawal.created (PENDING) → withdrawal.status.updated (FAILED, transactionHash: null) |
| Crypto deposit | deposit.created → deposit.status.updated (COMPLETED) |
| Simple conversion | order.created (NEW) → fill.created → order.status.updated (FILLED) |
| On-credit conversion | order.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.
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:
- Split the header on
.into<protected>, an empty segment, and<signature>. - Look up the JWK whose
kidmatches the protected header's (cache the JWKS). - Rebuild the signing input by re-inserting the payload:
<protected> + "." + base64url(rawBody). - Verify the Ed25519 signature over that input.
- Check
skipo.io/iatagainst your clock — reject anything more than 300 seconds (5 minutes) out, in either direction. This is what stops a captured delivery being replayed.
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.
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.