Callbacks
Polling the portfolio endpoints tells you what a user holds now. Callbacks tell you the moment it changed - a deposit landing, a position opening, a position closing, a bridge arriving - so you can react without a poll loop.
You give us one HTTPS endpoint. We POST a signed JSON body to it every time
something happens to one of your users, or to a pool in your catalogue. You verify the signature, return a
2xx, and do the work afterwards.
Setup is done during onboarding: send us the URL and we return a signing secret. There is no self-service endpoint for either - the URL decides where your users' data is sent, so it stays behind our admin controls.
The envelope
Every callback, portfolio, bridge and pool alike, goes to that one endpoint
with the same shape. event says what happened - route on it (it is also in the
X-Restake-Event header). data is exactly what the matching REST read returns
for that object, so you can pass it to the same parser.
Code
userId is the user the event is about. It is null for
pool events, which are not about any one user.
Alongside it, four headers:
| Header | Purpose |
|---|---|
X-Restake-Signature | t=<unix>,v1=<hmac> - see below |
X-Restake-Event | Same as event in the body, for routing before parsing |
X-Restake-Event-Id | Same as id. Identical across retries - deduplicate on it |
X-Restake-Delivery-Attempt | 1 on the first try, up to 4 |
Events
| Event | Fires when | data matches |
|---|---|---|
portfolio_transaction_received | A transfer to or from the user's wallet is indexed | GET …/portfolio/wallet/transactions |
portfolio_position_opened | A staking position is created | GET …/portfolio/positions/open/staking/list |
portfolio_position_closed | A position's balance reaches zero | same |
portfolio_position_liquidity_added | An open position's amount increases | same |
portfolio_position_liquidity_removed | An open position's amount decreases | same |
portfolio_snapshot_created | The valuation job writes a new portfolio value | GET …/portfolio/wallet/snapshot/history |
callback_test | You asked us to send a test | { message, partner, sentAt } |
portfolio_transaction_received fires once per side that belongs to you. If
both the sender and the receiver are your users, you get two callbacks for the
one transfer, each with direction set from that user's point of view.
portfolio_snapshot_created is the high-volume one - it fires per user per
valuation run. Tell us if you would rather not receive it.
Bridge events
Delivered to the same endpoint as every other event. Every one carries the
transfer as
GET …/bridge/transfers/{transferId} returns it, with status already moved to
the state the event names. See Bridge for what each status
means.
| Event | Fires when |
|---|---|
bridge_transfer_submitted | The signed bridge reached the bundler |
bridge_transfer_source_confirmed | The deposit landed on the source chain |
bridge_transfer_delivered | The tokens arrived on the destination chain |
bridge_transfer_refund_pending | The deposit expired unfilled; a refund to the source chain is on its way |
bridge_transfer_refunded | The refund landed on the source chain |
bridge_transfer_failed | The operation reverted or never reached the bundler; nothing moved |
Code
A transfer announces each status once. A fast bridge can move on before an
event is sent - a transfer that is delivered by the time
bridge_transfer_source_confirmed would go out skips that event and sends
bridge_transfer_delivered instead. Key your handling on data.status, not on
having seen every step.
Pool events
staking_pool_utilization_changed fires when a pool's exit-liquidity level
changes - up (normal → high → critical, or straight to critical) and
back down. It is sent once to every partner whose catalogue lists the pool,
whether or not any of your users hold it, with userId: null. A pool you
switched off in the panel is not sent.
data.utilization is exactly the utilization block of the pool read
(GET …/staking-pools/simple-strategy/table; see
Exit liquidity), so if you miss or
dead-letter an event, re-read the pool to catch up.
Code
level | Entered at | Left below |
|---|---|---|
high | 90% | 87% |
critical | 98% | 95% |
A pool sitting at 97% does not send an event every hour - only a change of
level does. Expect a few a day at most across the whole catalogue. When this
event first ships, a pool that is already high or critical sends one event
so you start from the current state.
Verifying the signature
Do this before parsing the body. An endpoint that acts on unverified callbacks is an endpoint anyone on the internet can drive.
The header looks like this:
Code
Compute HMAC-SHA256(secret, "<t>.<raw body>") and compare it to v1. Two
details matter: use the raw request body, before any JSON parsing - a
re-serialized object will not match - and compare in constant time.
Code
Wiring it into an endpoint is where the raw body is usually lost. A JSON body parser replaces it with an object, and re-serializing that object produces different bytes - different key order, different number formatting - so the signature will not match. Keep the buffer:
Code
verify above takes the body as a string; a Buffer works unchanged, since
createHmac().update() accepts either and `${timestamp}.${rawBody}`
becomes a template concatenation. If you pass a Buffer, sign it explicitly to
avoid the implicit toString:
Code
Rotating the secret
A rotation takes effect immediately: from the moment we issue the new secret,
callbacks are signed with it alone and the previous one verifies nothing. There
is no overlap window, so tell us when you are ready and deploy the new secret as
soon as you have it. Anything that arrives in between fails your check - and if
you answer it with a 4xx, that is a permanent refusal to us and the event is
not retried.
Two ways to keep the gap harmless: have your endpoint accept either secret while
you roll (the check above already loops over the v1= values, so it is one more
candidate), or answer with a 5xx when verification fails, which puts the event
on the retry schedule below instead of dropping it.
Retries
An attempt succeeds on any 2xx. Anything else is retried on this schedule:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | +1 minute |
| 3 | +3 minutes |
| 4 | +5 minutes |
After the fourth failure the event is dead-lettered and not retried again. We keep the record and can tell you exactly what your endpoint returned.
A 4xx other than 408 and 429 stops the retries immediately. If your
endpoint answers 400 or 404, we take it at its word - the same body will not
succeed later. Return 503 if you want the event redelivered.
Should your endpoint fail continuously, delivery is switched off for your account and we contact you. Re-enabling is a manual step on our side, so the backlog does not arrive as a surprise.
What your endpoint must do
- Answer within 10 seconds. We give up after that and the attempt counts as
failed. Acknowledge first, process afterwards - write the event to your own
queue and return
200. - Be idempotent. Delivery is at-least-once: a timeout on our side after your
handler committed means you will see that
X-Restake-Event-Idagain. Treat a repeat as a no-op. - Do not depend on ordering. Retries interleave with new events, so a
portfolio_position_closedcan arrive before theportfolio_position_liquidity_removedthat preceded it. Every payload carries the object's current state, so the latest one wins. The same holds for bridge events: judge a transfer bydata.statusanddata.updatedAt. - Serve HTTPS with a valid certificate. Plain HTTP is rejected, as are private and loopback addresses. At most one redirect is followed.
Testing your integration
Ask us to fire a callback_test event. It goes through the real pipeline -
signed the same way, recorded and retried the same way - so a test that arrives
and verifies means the real events will too. We can also read you the delivery
log for your account: every attempt, with the status code and the first part of
the response your endpoint returned.
