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

```json
{
  "id": "9f2c1e40-5c1a-4f0e-9c7b-2d4a6b8e0f11",
  "event": "portfolio_position_opened",
  "occurredAt": "2026-08-13T10:00:00.000Z",
  "userId": "22222222-2222-2222-2222-222222222222",
  "data": {
    "positionId": "b3f1…",
    "poolSlug": "steakhouse-usdc",
    "status": "open",
    "amount": 1000.0,
    "openUsdValue": 1000.0,
    "currentUsdValue": 1004.2,
    "unrealizedProfitUsd": 4.2
  }
}
```

`userId` is the user the event is about. It is `null` for
[pool events](#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](/bridge#statuses) 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 |

```json
{
  "id": "0c7e5d1a-…",
  "event": "bridge_transfer_delivered",
  "occurredAt": "2026-09-24T10:00:09.000Z",
  "userId": "8f2b1c4e-…",
  "data": {
    "transferId": "d41e…",
    "userId": "8f2b1c4e-…",
    "routeId": "5c0a…",
    "status": "delivered",
    "provider": "across",
    "providerRef": "3141592",
    "source": { "chain": "BASE", "chainId": 8453, "token": "USDC", "amount": "100.0", "amountRaw": "100000000", "…": "…" },
    "destination": { "chain": "BSC", "chainId": 56, "token": "USDC", "amount": "99.9859", "amountRaw": "99985900000000000000", "…": "…" },
    "srcTxHash": "0x…",
    "dstTxHash": "0x…",
    "…": "…"
  }
}
```

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](/staking#exit-liquidity-utilization)), so if you miss or
dead-letter an event, re-read the pool to catch up.

```json
{
  "id": "7a1d3f02-…",
  "event": "staking_pool_utilization_changed",
  "occurredAt": "2026-09-28T10:00:03.000Z",
  "userId": null,
  "data": {
    "poolSlug": "gauntlet-usdc-prime-base",
    "chain": "BASE",
    "previousLevel": "high",
    "utilization": {
      "level": "critical",
      "rate": 98.62,
      "availableLiquidity": 2466210.4,
      "baseTokenSymbol": "USDC",
      "observedAt": "2026-09-28T10:00:01.000Z"
    }
  }
}
```

| `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:

```
X-Restake-Signature: t=1786608000,v1=5257a869e7ecebeda32affa62cdca3fa793333e4a0f81ea0e2b0e4a6ad4ba1e1
```

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.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=')),
  );
  const timestamp = Number(parts.t);

  // Rejects a replayed callback. The timestamp is inside the signed material,
  // so it cannot be changed without breaking the signature.
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest();

  // The scheme allows several `v1=` values; accept if any matches.
  return header
    .split(',')
    .filter((p) => p.trim().startsWith('v1='))
    .some((p) => {
      const candidate = Buffer.from(p.trim().slice(3), 'hex');
      return (
        candidate.length === expected.length &&
        timingSafeEqual(candidate, expected)
      );
    });
}
```

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:

```js
import express from 'express';

const app = express();
const secret = process.env.RESTAKE_CALLBACK_SECRET;

// `express.raw` hands the handler the exact bytes we signed. With
// `express.json` you would have to use its `verify` hook to stash `req.rawBody`
// before it parses.
app.post(
  '/restake/callbacks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.get('X-Restake-Signature');

    if (!header || !verify(req.body, header, secret)) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString('utf8'));

    // Acknowledge first - you have 10 seconds. Deduplicate on the event id,
    // then do the real work off the request path.
    res.sendStatus(200);

    queue.push({ id: req.get('X-Restake-Event-Id'), event });
  },
);
```

`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`:

```js
const expected = createHmac('sha256', secret)
  .update(`${timestamp}.`)
  .update(rawBody)
  .digest();
```

### 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-Id` again. Treat a
  repeat as a no-op.
- **Do not depend on ordering.** Retries interleave with new events, so a
  `portfolio_position_closed` can arrive before the
  `portfolio_position_liquidity_removed` that preceded it.
  Every payload carries the object's current state, so the latest one wins.
  The same holds for bridge events: judge a transfer by `data.status` and
  `data.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.
