> For the complete documentation index, see [llms.txt](https://hinkal-team.gitbook.io/hinkal/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://hinkal-team.gitbook.io/hinkal/hinkal-api/description/private-send.md).

# 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

```js
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](/hinkal/hinkal-api/description/authentication.md) 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.

{% tabs %}
{% tab title="EVM" %}

```js
const { secp256k1 } = require("@noble/curves/secp256k1");
const { createHash, randomBytes, randomUUID } = require("crypto");

function normalizeRoutePath(path) {
  const withSlash = path.startsWith("/") ? path : `/${path}`;
  return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}

function buildActionBinding(method, routePath) {
  return `${method.toUpperCase()} ${normalizeRoutePath(routePath)}`;
}

function signPayload(privateKey, payload) {
  const msgHash = new Uint8Array(createHash("sha256").update(payload).digest());
  return Buffer.from(secp256k1.sign(msgHash, privateKey).toBytes("compact")).toString("hex");
}

async function createSession(wallet, expiresAt) {
  // 1. Generate secp256k1 key pair
  const privateKey = randomBytes(32);
  const clientPublicKey = Buffer.from(secp256k1.getPublicKey(privateKey, true)).toString("hex");

  // 2. Sign session message with wallet
  const sessionId = randomUUID();
  const lines = [
    "Authorize Hinkal session",
    `Session ID: ${sessionId}`,
    `Public Key: ${clientPublicKey}`,
    "This signature can also be used to submit transactions.",
  ];
  const signature = await wallet.signMessage(lines.join("\n"));

  // 3. POST with x-hinkal-request-signature header
  const nonce = randomUUID();
  const body = {
    signature,
    address: wallet.address,
    sessionId,
    clientPublicKey,
    nonce,
    useEIP712: false,
    ...(expiresAt ? { expiresAt } : {}),
  };

  const res = await fetch(`${BASE_URL}/create-session`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-hinkal-request-signature": signPayload(privateKey, JSON.stringify(body)),
    },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!json.success) throw new Error(json.error);
  return { sessionId, privateKey };
}
```

{% endtab %}

{% tab title="Tron" %}

```js
const { secp256k1 } = require("@noble/curves/secp256k1");
const { createHash, randomBytes, randomUUID } = require("crypto");

function normalizeRoutePath(path) {
  const withSlash = path.startsWith("/") ? path : `/${path}`;
  return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}

function buildActionBinding(method, routePath) {
  return `${method.toUpperCase()} ${normalizeRoutePath(routePath)}`;
}

function signPayload(privateKey, payload) {
  const msgHash = new Uint8Array(createHash("sha256").update(payload).digest());
  return Buffer.from(secp256k1.sign(msgHash, privateKey).toBytes("compact")).toString("hex");
}

async function createSession(tronAddress, tronPrivateKey) {
  const privateKey = randomBytes(32);
  const clientPublicKey = Buffer.from(secp256k1.getPublicKey(privateKey, true)).toString("hex");

  const sessionId = randomUUID();
  const lines = [
    "Authorize Hinkal session",
    `Session ID: ${sessionId}`,
    `Public Key: ${clientPublicKey}`,
    "This signature can also be used to submit transactions.",
  ];
  const signature = tronWeb.trx.signMessageV2(lines.join("\n"), tronPrivateKey);

  const nonce = randomUUID();
  const body = { signature, address: tronAddress, sessionId, clientPublicKey, nonce, useEIP712: false };

  const res = await fetch(`${BASE_URL}/create-session`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-hinkal-request-signature": signPayload(privateKey, JSON.stringify(body)),
    },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!json.success) throw new Error(json.error);
  return { sessionId, privateKey };
}
```

{% endtab %}

{% tab title="Solana" %}

```js
import nacl from "tweetnacl";
const { secp256k1 } = require("@noble/curves/secp256k1");
const { createHash, randomBytes, randomUUID } = require("crypto");

function normalizeRoutePath(path) {
  const withSlash = path.startsWith("/") ? path : `/${path}`;
  return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}

function buildActionBinding(method, routePath) {
  return `${method.toUpperCase()} ${normalizeRoutePath(routePath)}`;
}

function signPayload(privateKey, payload) {
  const msgHash = new Uint8Array(createHash("sha256").update(payload).digest());
  return Buffer.from(secp256k1.sign(msgHash, privateKey).toBytes("compact")).toString("hex");
}

async function createSession(keypair, address) {
  const privateKey = randomBytes(32);
  const clientPublicKey = Buffer.from(secp256k1.getPublicKey(privateKey, true)).toString("hex");

  const sessionId = randomUUID();
  const lines = [
    "Authorize Hinkal session",
    `Session ID: ${sessionId}`,
    `Public Key: ${clientPublicKey}`,
    "This signature can also be used to submit transactions.",
  ];
  const message = lines.join("\n");
  const signature = Buffer.from(
    nacl.sign.detached(Buffer.from(message, "utf8"), keypair.secretKey)
  ).toString("hex");

  const nonce = randomUUID();
  const body = { signature, address, sessionId, clientPublicKey, nonce, useEIP712: false };

  const res = await fetch(`${BASE_URL}/create-session`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-hinkal-request-signature": signPayload(privateKey, JSON.stringify(body)),
    },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!json.success) throw new Error(json.error);
  return { sessionId, privateKey };
}
```

{% endtab %}
{% endtabs %}

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

```js
const nonce = randomUUID();
const body = {
  sessionId: session.sessionId,
  nonce,
  chainId: CHAIN_ID,
  tokenAddress: TOKEN_ADDRESS,
  recipients: [
    { address: "0xRecipientA...", amount: "200000" }, // 0.2 USDC
    { address: "0xRecipientB...", amount: "100000" }, // 0.1 USDC
  ],
  feeToken: TOKEN_ADDRESS, // optional — defaults to tokenAddress
  txCompletionTime: Math.floor(Date.now() / 1000) + 300, // optional Unix seconds
  timestamp: Date.now(),
};
const order = await post("/private-send", body, {
  "x-hinkal-request-signature": signPayload(
    session.privateKey,
    `${buildActionBinding("POST", "/private-send")}\n${JSON.stringify(body)}`,
  ),
});
```

**`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.

```js
// Schedule withdrawals to complete within the next 5 minutes
txCompletionTime: Math.floor(Date.now() / 1000) + 300

// Or dispatch immediately (default when omitted)
```

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:

```js
if (order.approvalAddress) {
  const token = new ethers.Contract(TOKEN_ADDRESS, ERC20_ABI, wallet);
  await (await token.approve(order.approvalAddress, BigInt(order.amountIn))).wait();
}
```

**Broadcast the deposit** by chain:

{% tabs %}
{% tab title="EVM" %}

```js
const tx = ethers.Transaction.from(
  "0x" + Buffer.from(order.serializedTx, "base64").toString("hex")
);
tx.nonce = await provider.getTransactionCount(wallet.address, "pending");
const signed = await wallet.signTransaction(tx);
const depositHash = await provider.send("eth_sendRawTransaction", [signed]);
```

{% endtab %}

{% tab title="Tron" %}

```js
const txObj = JSON.parse(Buffer.from(order.serializedTx, "base64").toString("utf8"));
const signed = await tronWeb.trx.sign(txObj, privateKey);
const result = await tronWeb.trx.sendRawTransaction(signed);
const depositHash = result.txid;
```

{% endtab %}

{% tab title="Solana" %}

```js
// No approval needed on Solana (approvalAddress is always null)
const txBytes = Buffer.from(order.serializedTx, "base64");
// Use @solana/web3.js: Transaction.from(txBytes), set recentBlockhash, sign, sendRawTransaction
const transaction = Transaction.from(txBytes);
transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
transaction.sign(keypair);
const depositHash = await connection.sendRawTransaction(transaction.serialize());
```

{% endtab %}
{% endtabs %}

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

```js
const TX_TERMINAL = new Set(["completed", "failed"]);
const POLL_INTERVAL_MS = 5_000;
const POLL_TIMEOUT_MS = 10 * 60_000; // 10 minutes

function allSettled(txs) {
  return txs && txs.length > 0 && txs.every((tx) => TX_TERMINAL.has(tx.status));
}

async function pollOrder(session, orderId) {
  const deadline = Date.now() + POLL_TIMEOUT_MS;
  while (Date.now() < deadline) {
    const params = new URLSearchParams({ sessionId: session.sessionId, nonce: randomUUID() });
    const queryString = params.toString();
    const binding = buildActionBinding("GET", "/private-send/:orderId");
    const res = await fetch(`${BASE_URL}/private-send/${orderId}?${queryString}`, {
      headers: {
        "x-hinkal-request-signature": signPayload(session.privateKey, `${binding}\n${queryString}`),
      },
    });
    const data = await res.json();
    if (data.status === "failed") return data;
    if (data.status === "scheduled" && allSettled(data.scheduledTransactions)) return data;
    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
  }
  throw new Error(`Order ${orderId} timed out`);
}

const result = await pollOrder(session, order.orderId);
if (result.status === "failed") throw new Error("Order failed");
console.log(result.scheduledTransactions);
```

`scheduledTransactions` has one entry per recipient:

```json
[
  { "status": "completed", "scheduledTime": "2025-01-01T00:00:00Z", "txHash": "0x..." },
  { "status": "completed", "scheduledTime": "2025-01-01T00:00:01Z", "txHash": "0x..." }
]
```

***

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