Integration example
This page shows a complete Lido stVault flow with the Staking API (craft → sign → prepare → broadcast), with the signing step. The example uses the stake action with ETH only. The same four steps apply to claim, unstake, withdraw, burn-wsteth, withdraw-pool, claim-pool-withdrawal and refresh-report: only the endpoint and the request body change.
You sign in your own environment. The Stakely Staking API never has access to your private keys. This page shows two signing options: a local private key and an external custodian (Fireblocks). Lido stVault runs on Ethereum, so standard EVM tools apply.
Lido stVault is available on Hoodi. Set X-NETWORK to 560048. Read the enabled networks with the networks endpoint.
Do you need testnet tokens to try the flow? Contact us at [email protected] and we can provide them.
Setup
This example targets Node.js 24 (LTS). Install the dependencies:
npm install ethereumjs-util dotenv
Fill in your values in .env:
STAKELY_API_KEY=
STAKELY_API_BASE_URL=https://staking-api.stakely.io
STAKELY_NETWORK=560048
EVM_CHAIN_ID=560048
ETHEREUM_WALLET_ADDRESS=
ETHEREUM_WALLET_PRIVATE_KEY=
A small helper calls the API with the required headers:
require('dotenv').config();
const BASE = `${process.env.STAKELY_API_BASE_URL}/api/v1/ethereum/lido-stvault`;
const headers = {
'X-API-KEY': process.env.STAKELY_API_KEY,
'X-NETWORK': process.env.STAKELY_NETWORK,
'Content-Type': 'application/json',
};
// fetch is available globally in Node.js 24
const api = async (method, path, body) => {
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${path} → ${res.status}: ${await res.text()}`);
return res.json();
};
1. Craft the transaction
Ask the API to build the unsigned stake transaction. This request stakes ETH only, without mint_wsteth_amount.
const crafted = await api('POST', '/action/stake', {
address: process.env.ETHEREUM_WALLET_ADDRESS,
amount: 0.01,
});
// crafted = { serialized_tx_hex, unsigned_tx_hash_hex, payload }
The API simulates the transaction before it returns it. If the protocol cannot accept the stake in the current on-chain state, the request fails with 422 and a reason. See Errors.
Before you sign, examine crafted.payload. Make sure that to is the strategy contract and that value is the amount that you stake.
2. Sign the transaction
Sign the unsigned_tx_hash_hex value. Use one of the two options below. Both options return the signature parts { r, s, v } in the format that the prepare step needs:
randsare hex strings without the0xprefix.vis in EIP-155 form:chain_id * 2 + 35 + recovery.
The prepare step also accepts r and s with the 0x prefix, and v as the recovery id (0 or 1) or as 27 or 28. It converts v to the EIP-155 value.
Option A: Local private key
const ethUtil = require('ethereumjs-util');
const signWithPrivateKey = ({ unsigned_tx_hash_hex }) => {
const txHashBytes = Buffer.from(unsigned_tx_hash_hex.replace(/^0x/, ''), 'hex');
const privateKeyBuffer = Buffer.from(process.env.ETHEREUM_WALLET_PRIVATE_KEY, 'hex');
// ecsign with the chain id produces the EIP-155 v the API expects
const { r, s, v } = ethUtil.ecsign(
txHashBytes,
privateKeyBuffer,
Number(process.env.EVM_CHAIN_ID),
);
return { r: r.toString('hex'), s: s.toString('hex'), v };
};
const { r, s, v } = signWithPrivateKey(crafted);
Option B: External custodian (Fireblocks)
Your application never has the private keys. Fireblocks signs in its secure environment. Install and configure the SDK:
npm install fireblocks-sdk
FIREBLOCKS_API_SECRET=
FIREBLOCKS_API_KEY=
FIREBLOCKS_BASE_URL=
FIREBLOCKS_VAULT_ID=
FIREBLOCKS_ASSET_ID=
const { FireblocksSDK, TransactionOperation, PeerType, TransactionStatus } = require('fireblocks-sdk');
const fireblocks = new FireblocksSDK(
process.env.FIREBLOCKS_API_SECRET,
process.env.FIREBLOCKS_API_KEY,
process.env.FIREBLOCKS_BASE_URL,
);
const waitForTxCompletion = async (fbTx) => {
let tx = fbTx;
while (tx.status !== TransactionStatus.COMPLETED) {
if (
tx.status === TransactionStatus.BLOCKED ||
tx.status === TransactionStatus.FAILED ||
tx.status === TransactionStatus.CANCELLED
) {
throw new Error(`Fireblocks signer: the transaction has been ${tx.status}`);
} else if (tx.status === TransactionStatus.REJECTED) {
throw new Error('Fireblocks signer: the transaction was rejected; check your TAP security policy');
}
tx = await fireblocks.getTransactionById(fbTx.id);
}
return fireblocks.getTransactionById(fbTx.id);
};
const signWithFireblocks = async ({ unsigned_tx_hash_hex }) => {
const content = unsigned_tx_hash_hex.replace(/^0x/, '');
const fbTx = await fireblocks.createTransaction({
// The Fireblocks asset of the network you target with X-NETWORK (the Hoodi test asset).
assetId: process.env.FIREBLOCKS_ASSET_ID,
operation: TransactionOperation.RAW,
source: { type: PeerType.VAULT_ACCOUNT, id: process.env.FIREBLOCKS_VAULT_ID },
note: 'Sign transaction from Stakely Staking API',
extraParameters: { rawMessageData: { messages: [{ content }] } },
});
const result = await waitForTxCompletion(fbTx);
const signature = result.signedMessages[0].signature;
// Fireblocks returns r and s without the 0x prefix, and v as the recovery value (0 or 1)
return {
r: signature.r,
s: signature.s,
v: Number(process.env.EVM_CHAIN_ID) * 2 + 35 + signature.v,
};
};
const { r, s, v } = await signWithFireblocks(crafted);
3. Prepare the signed transaction
Send the serialized_tx_hex value with the signature parts to get the signed transaction.
const prepared = await api('POST', '/action/prepare', {
serialized_tx_hex: crafted.serialized_tx_hex,
r,
s,
v,
});
// prepared = { signed_tx_hex }
4. Broadcast
Submit the signed transaction. You can use the Stakely broadcast endpoint or your own RPC.
const { tx_hash } = await api('POST', '/action/broadcast', {
signed_tx_hex: prepared.signed_tx_hex,
});
console.log('Broadcasted:', tx_hash);
The broadcast submits the transaction. To confirm that it is final, poll tx_hash on chain. See Errors & responses for how to handle results and failures.
5. Read the position
After the transaction is confirmed, read the position. The position is on your call forwarder, and the response contains its address.
const position = await api('GET', `/balance/${process.env.ETHEREUM_WALLET_ADDRESS}`);
console.log('Call forwarder:', position.call_forwarder);
console.log('STV:', position.stv, '=', position.stv_assets, 'ETH');
The same response tells you when the next action is possible:
| Field | Next action |
|---|---|
pending_deposit_is_claimable is true | claim |
shares is greater than 0 | unstake |
exit_requests[].is_claimable is true | withdraw with that request_id |
wsteth and minted_steth_shares are greater than 0 | burn-wsteth |
stv is greater than 0 | withdraw-pool |
pool_withdrawal_requests[].is_claimable is true | claim-pool-withdrawal with that request_id |
Next steps
- Full request and response schemas for every action: Endpoints.
- The full exit of a position to ETH: Staking Flow.
- What to do when an action returns
VaultReportStale: Refresh the vault report. - The same four-step flow on other networks and the conceptual overview: How it works.