Private Send
POST /private-send lets you send funds to one or more recipients privately in a single order. The caller deposits public tokens; the enclave detects the on-chain deposit and schedules a private withdrawal to each recipient - the on-chain link between sender and recipients is never visible.
The flow works on EVM chains, TRON, and Solana. The request body shape is the same across chains; the signed transaction format returned in serializedTx differs:
Chain
serializedTx format
approvalAddress
EVM
Base64-encoded RLP-serialized EIP-1559 transaction
ERC20 spender address
Tron
Base64-encoded JSON-serialized Tron transaction
ERC20 spender address
Solana
Base64-encoded serialized Solana transaction
Always null
Complete integration
const { ethers } = require("ethers");
const { randomUUID } = require("crypto");
const BASE_URL = "https://api.hinkal.io";
const ERC20_ABI = ["function approve(address,uint256) returns (bool)"];
async function privateSend(wallet, provider, chainId, tokenAddress, recipients, feeToken, txCompletionTime) {
// 1. Authenticate — normal mode session (secp256k1 request signature)
const session = await createSession(wallet);
// 2. Prepare the order
const nonce = randomUUID();
const body = {
sessionId: session.sessionId,
nonce,
chainId,
tokenAddress,
recipients, // [{ address, amount }, ...]
feeToken,
timestamp: Date.now(),
...(txCompletionTime !== undefined && { txCompletionTime }),
};
const order = await post("/private-send", body, {
"x-hinkal-request-signature": signPayload(
session.privateKey,
`${buildActionBinding("POST", "/private-send")}\n${JSON.stringify(body)}`,
),
});
// order: { orderId, approvalAddress, serializedTx, amountIn, amountOut, fee, nonce }
// 3. Approve if required (EVM/Tron only — Solana approvalAddress is always null)
if (order.approvalAddress) {
const token = new ethers.Contract(tokenAddress, ERC20_ABI, wallet);
await (await token.approve(order.approvalAddress, BigInt(order.amountIn))).wait();
}
// 4. Sign and broadcast the deposit (EVM example — see chain-specific snippets below)
const tx = ethers.Transaction.from("0x" + Buffer.from(order.serializedTx, "base64").toString("hex"));
tx.nonce = await provider.getTransactionCount(wallet.address, "pending");
const depositHash = await provider.send("eth_sendRawTransaction", [await wallet.signTransaction(tx)]);
console.log("deposit:", depositHash);
// 5. Poll until all private withdrawals complete
const result = await pollOrder(session, order.orderId);
if (result.status === "failed") throw new Error("Order failed");
console.log("withdrawals:", result.scheduledTransactions);
return result;
}Step 1 - Authenticate
Every call to /private-send requires authentication. Use normal mode (secp256k1 request signature - no per-transaction wallet signature required) or EIP-712 mode (each transaction carries its own typed-data signature). See Authentication for full details.
The examples below use normal mode (the default). A secp256k1 key pair is generated once per session; all subsequent requests are authenticated with a x-hinkal-request-signature header.
Step 2 - Prepare the order
POST /private-send returns a serialized deposit transaction and fee breakdown. Pass a single tokenAddress and one or more recipients, each with their exact payout amount in the token's smallest unit.
txCompletionTime is an optional Unix timestamp in seconds. When set, it tells the relayer the deadline by which all withdrawal transactions must be completed. Omit it for immediate dispatch.
The response:
orderId
Track this - you need it to poll status
approvalAddress
ERC20 spender to approve on EVM/TRON; always null on Solana
serializedTx
Base64-encoded unsigned deposit transaction
amountIn
Total tokens you must send (recipients' amounts + fee)
amountOut
Sum of all recipient amounts
fee
Protocol fee deducted from amountIn
nonce
Per-request nonce echoed back - present for all authenticated requests
Step 3 - Approve and broadcast
ERC20 approval (EVM / TRON) - if approvalAddress is non-null, approve amountIn first:
Broadcast the deposit by chain:
Step 4 - Poll the order status
Once the deposit confirms on-chain the enclave automatically schedules a private withdrawal to each recipient. Poll GET /private-send/{orderId} in two phases:
Wait for
statusto becomescheduled- the enclave has queued the withdrawals.Each withdrawal is done when its status is
completedorfailed. You can either wait forcompleted, or stop atsent_on_chain- at that pointtxHashis set and you can wait for on-chain confirmation yourself (with whatever block depth you choose).
Always bound the loop with a deadline.
GET /private-send/{orderId} requires an active session, same as any other read endpoint - pass sessionId and nonce in the query string and sign it. Because the route is parameterized, the binding uses the literal route pattern "GET /private-send/:orderId", not the concrete orderId from the URL.
scheduledTransactions has one entry per recipient:
Order statuses
processing
No
Deposit awaited, or withdrawals being scheduled
scheduled
Yes
Private withdrawals scheduled
failed
Yes
Deposit or withdrawal failed
Per-recipient withdrawal statuses
pending
Queued, not yet dispatched
processing
Being executed
waiting_for_relayer
Relayer is busy; withdrawal is queued to start
sent_on_chain
Submitted, awaiting on-chain confirmation (txHash available)
completed
Confirmed on-chain
failed
This recipient's withdrawal failed
Notes
amountInis what leaves your wallet;amountOutis what recipients collectively receive. The difference is the protocol fee.Amounts are always in the token's smallest unit (
"200000"= 0.2 USDC with 6 decimals).On Solana,
approvalAddressis alwaysnull- no ERC20 approval step needed.The caller signs with their own wallet - Hinkal never holds the caller's key.
Last updated