For the complete documentation index, see llms.txt. This page is also available as Markdown.

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:

Field
Description

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:

  1. Wait for status to become scheduled - the enclave has queued the withdrawals.

  2. Each withdrawal is done when its status is completed or failed. You can either wait for completed, or stop at sent_on_chain - at that point txHash is 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

Status
Terminal
Meaning

processing

No

Deposit awaited, or withdrawals being scheduled

scheduled

Yes

Private withdrawals scheduled

failed

Yes

Deposit or withdrawal failed

Per-recipient withdrawal statuses

Status
Meaning

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

  • amountIn is what leaves your wallet; amountOut is 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, approvalAddress is always null - no ERC20 approval step needed.

  • The caller signs with their own wallet - Hinkal never holds the caller's key.

Last updated