Curtain/ developersGet an API key

CURTAIN DEVELOPER PLATFORM

Private swaps.
Your application.

A small API for a complete swap journey. Quote an amount, prepare a deposit, and follow its delivery. Your users keep control of their wallets.

V2 · flexible amountsV3 · fixed denominationsV4 · shielded pool routeServer-side API keysUnsigned transactions
BASE URLhttps://operator.curtainrh.com/v1
01Quote

Choose tokens and a minimum output.

02Deposit

The user signs in their own wallet.

03Deliver

Track the intent until payout.

Choose privacyRoute: "v2" for flexible amounts, "v3" for approved fixed denominations, or "dynamic" to let Curtain select V3 for approved denominations and fall back to V2. The selected route is returned in every quote and intent response. Split payouts and stealth-address delivery are not part of this release.

FIRST REQUEST

Start with a key.

  1. Open Dashboard → Developer and connect an EOA wallet.
  2. Sign the access message, then name and create a key. Save it when it appears: it is only shown once.
  3. Store it in your server’s secret manager as CURTAIN_API_KEY. Call Curtain from your backend.
Terminal
curl https://operator.curtainrh.com/v1/config \
  -H "Authorization: Bearer $CURTAIN_API_KEY"

Read token addresses from configuration. Amounts are decimal strings in the token’s smallest units: for USDG (6 decimals), "10000000" means 10 USDG. Never use floating-point arithmetic to construct token amounts.

Keys for your server.
Signatures for your users.

Authenticate every /v1/* request with Authorization: Bearer <your-key>. Keys authorize API access, not spending. They cannot sign or broadcast a wallet transaction.

Never embed an API key in frontend JavaScript, a mobile app bundle, a URL, analytics, or a public repository. Your frontend calls your own backend; that backend calls Curtain.

Dashboard key management requires a wallet signature for each action. Challenges expire after five minutes and can only be used once for the exact signed action. This release supports EOA wallets; contract-wallet signatures are not supported.

You can have up to 10 active keys per wallet. Revocation blocks new API requests immediately. A new key cannot access intents belonging to an old key. Save your intent IDs and escape tickets; revocation does not cancel funded swaps or remove their on-chain refund rights.

GET/v1/config

Discover the network.

Returns chainId, privacyRoutes, vaults, a tokens map keyed by symbol, maxDelaySeconds, and rateLimitPerMinute. When V3 is enabled, it also returns v3FixedAmounts as token/amount pairs in raw units.

Use the returned addresses for quoting. V2 is 0xF9381841e982648c178E762116A437Ecbcf12Bbd; V3 is 0xBF643c56D6f1775f9ABe97b7B7e89b0265D6c67a.

POSThttps://operator.curtainrh.com/mcp

Connect an agent.

Curtain exposes the same Developer API through a Streamable HTTP MCP server. Connect an MCP-compatible agent with your API key as its Bearer credential. Agents can discover configuration, request quotes, select Dynamic Privacy, prepare unsigned swaps, or combine quoting and preparation into one call transactions, track intents, and inspect public keeper settlements.

MCP never receives a user private key and never broadcasts a user deposit. The agent returns unsigned approval and deposit transactions for the user's wallet to review and sign.
https://operator.curtainrh.com/mcp

Available tools include curtain_get_config, curtain_get_quote, curtain_batch_get_quotes, curtain_prepare_swap, curtain_quote_and_prepare_swap, curtain_get_intent_status, and curtain_list_pending_settlements.

GET/v1/quote

Get a quote.

Supply privacyRoute=v2, privacyRoute=v3, or privacyRoute=dynamic, tokenIn, tokenOut, and amountIn. Optional slippageBps defaults to 100 (1%) and accepts integers from 0 to 5000. V3 quotes are valid only for approved token denominations. Dynamic quotes use V3 when the input amount is approved and V2 otherwise; the response includes the resolved privacyRoute, the original requestedPrivacyRoute, and a routeReason such as approved_fixed_denomination or amount_not_in_v3_denomination_set.

10 USDG → NVDA
curl --get https://operator.curtainrh.com/v1/quote \
  -H "Authorization: Bearer $CURTAIN_API_KEY" \
  --data-urlencode 'privacyRoute=v2' \
  --data-urlencode 'tokenIn=0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168' \
  --data-urlencode 'tokenOut=0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC' \
  --data-urlencode 'amountIn=10000000' \
  --data-urlencode 'slippageBps=100'

The response includes available, marketOut, expectedOut, minOutSuggested, protocolFee, keeperFee, and venue. All token amounts are raw-unit strings. Check available before continuing, and use a fresh minOutSuggested when creating an intent.

Integrators may optionally pass integratorFeeBps and integratorFeeRecipient. The fee is capped at 100 bps, deducted from expectedOut, and returned as integratorFee in the quote.

A quote is an estimate, not a price reservation. A delayed swap may wait if the market cannot satisfy its minimum output.

GET/keeper/v1/settlements/pending

Run a permissionless keeper.

Poll the public feed with privacyRoute=v2 or privacyRoute=v3. Each response contains operator-signed settlement data, the correct vault, and the chain ID. Simulate vault.settle(...) and submit it from your own funded wallet. The vault pays the keeper fee to the submitting address.

Pending V2 settlements
curl --get https://operator.curtainrh.com/keeper/v1/settlements/pending \
  --data-urlencode 'privacyRoute=v2'

No API key is required. A keeper cannot alter recipients, amounts, fees, or swap data: the vault verifies the operator signature. The legacy /settlements/pending endpoint remains available for existing keepers.

POST/v1/intents

Prepare the swap.

Create an intent on your server. Send Content-Type: application/json and a unique Idempotency-Key for each logical swap. Keep that key when retrying the same request.

Field Meaning
privacyRoute Required. "v2" for flexible amounts, "v3" for an approved fixed denomination, or "dynamic" to select automatically.
tokenIn / tokenOut Supported token contract addresses.
amountIn / minOut Positive uint256 decimal strings in raw token units.
depositor The wallet that will sign and fund the deposit.
recipient The address receiving the output. Required; there is no implicit default.
integratorFee Optional object with recipient and bps (0–100). The fee is deducted from output and paid as a separate signed payout.
delaySeconds Optional integer; defaults to 0. Maximum 15,552,000 (180 days).
orderType / expiresInSeconds Use orderType: "limit" with an explicit minOut and expiry from 60 seconds to 180 days. The operator waits for a qualifying quote.

Zero means no intentional delay; confirmations and operator processing still take time. A positive value allows a randomized payout time inside that window. The operator enforces the 180-day cap.

Limit orders do not use a delivery delay. Set orderType: "limit" and expiresInSeconds; settlement starts when the live quote can satisfy minOut. If the target is not reached, the escape ticket can be used after expiry.

Your backend · JavaScript
const base = "https://operator.curtainrh.com/v1";
const headers = {
  Authorization: `Bearer ${process.env.CURTAIN_API_KEY}`,
  "Content-Type": "application/json",
};
async function call(path, options = {}) {
  const res = await fetch(base + path, { ...options,
    headers: { ...headers, ...options.headers } });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error);
  return body;
}

// Obtain these from your authenticated application's swap request.
async function prepareSwap({ depositor, recipient, requestId }) {
  const tokenIn = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
  const tokenOut = "0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC";
  const amountIn = "10000000";
  const query = new URLSearchParams({
    privacyRoute: "dynamic", tokenIn, tokenOut, amountIn,
  });
  const quote = await call(`/quote?${query}`);
  if (!quote.available) throw new Error("No quote available");

  return call("/intents", {
    method: "POST",
    headers: { "Idempotency-Key": requestId },
    body: JSON.stringify({ privacyRoute: "dynamic", tokenIn, tokenOut,
      amountIn, minOut: quote.minOutSuggested,
      depositor, recipient, delaySeconds: 0 }),
  });
}

New intents return 201; identical retries return 200. Retrying with the same key and different parameters returns 409. Persist the original body for retries, including minOut; do not fetch a new quote and reuse an old idempotency key. Retries do not renew the original deadline.

The response contains id, the selected privacyRoute, the original requestedPrivacyRoute, and routeReason, chainId, the selected vault, escapeTicket, and transactions.approval / transactions.deposit. V3 tickets include a tag and must use the V3 refund function. Each transaction contains chainId, from, to, data, and value: "0". No transaction is broadcast by this endpoint.

Hand off to the wallet.

  1. Save the intent ID and escape ticket before asking the user to deposit. Provide the ticket to the user so recovery does not depend on your server.
  2. Verify that the connected account matches depositor and switch to the returned chain ID.
  3. Read the token’s allowance for the vault. If it is insufficient, request the returned approval transaction and wait for a successful receipt. Some tokens require setting an existing nonzero allowance to zero first.
  4. Ask the user to sign the deposit transaction. Wait for its receipt, verify success, and decode the vault’s Deposited event to obtain the depositId.
  5. Add depositId and the deposit transaction hash to your saved ticket. Start polling the intent.

Validate the returned transaction addresses, calldata, chain, and escape-ticket hash before presenting them to the wallet. Use the verified V2 vault ABI in the Curtain SDK source. A wallet estimates gas and supplies its own nonce.

Browser · an already configured wallet client
// prepared came from YOUR backend. No API key in this code.
const tx = prepared.transactions.deposit;
// First verify the chain, connected account, vault and calldata.
const depositHash = await walletClient.sendTransaction({
  account: connectedAccount,
  chain: robinhoodChain,
  to: tx.to,
  data: tx.data,
  value: BigInt(tx.value),
});
const receipt = await publicClient.waitForTransactionReceipt({
  hash: depositHash,
});
if (receipt.status !== "success") throw new Error("Deposit reverted");
// Decode Deposited from the expected vault and save its depositId.
GET/v1/intents/{id}

Follow delivery.

Use the same API key that created the intent. The response includes id, status, depositId, amountOut, payoutTx, and blockedReason. Fields are null until available. Unknown intents and intents owned by another key return 404.

Terminal
curl "https://operator.curtainrh.com/v1/intents/$INTENT_ID" \
  -H "Authorization: Bearer $CURTAIN_API_KEY"

Typical progression: awaiting_deposit → deposited → settling → paid. Other states include blocked, expired, refund_requested, refunded, and challenged. expired is not proof of an on-chain refund; check the deposit and retain the ticket.

Poll from your server with backoff, starting around every 5–10 seconds. Share a single poll across your app’s clients, reduce frequency for delayed delivery, and stop once paid, refunded, or challenged. All polling shares the key’s request budget.

Keep the escape ticket.

The intent response supplies vault, deadline, salt, and deadlineHash. After deposit, retain the on-chain depositId too. Store the ticket securely and make it recoverable by the user; do not put it into logs or analytics.

For V2, verify deadlineHash = keccak256(abi.encode(uint256 deadline, bytes32 salt)). For V3, verify deadlineHash = keccak256(abi.encode(uint256 deadline, bytes32 salt, bytes32 tag)). The V2 refund call is requestRefund(depositId, deadline, salt); V3 uses requestRefund(depositId, deadline, salt, tag). Both open after the deadline plus 180 seconds and have a 3,600-second challenge window.

Refunds are on-chain wallet transactions, not API calls. Read the deposit’s current state before acting. Already-paid deposits can be challenged; an escape ticket does not entitle a user to both a payout and a refund.

Revoking an API key does not change these contract rules. Your integration should offer recovery even when its API key or the operator is unavailable.

Predictable errors. Clear limits.

Errors return JSON: { "error": "Description" }. Successful responses and errors are marked Cache-Control: no-store.

Status What to do
400 Correct invalid input or unsupported privacy route.
401 Check your bearer key; it may be invalid or revoked.
404 Check the endpoint, intent ID, and the key that created it.
409 An idempotency key was reused with different parameters, or the wallet has 10 active keys.
413 Reduce the JSON request body to at most 16 KB.
429 Back off according to Retry-After. Each key allows 60 requests per minute across all API endpoints.
503 Retry with backoff and the original idempotency key/body. Do not assume a timed-out creation failed.

Never automatically broadcast the same deposit again after an API or wallet timeout. Check for an existing receipt and reconcile the intent first. The API’s idempotency guarantee covers intent creation, not repeated on-chain transactions.

Understand the privacy model.

Curtain V2 supports flexible amounts and randomized delivery windows. The operator knows the intended recipient and swap details. On-chain deposits and payouts are public, and amounts or timing can allow observers to correlate them. V2 does not guarantee anonymity or unlinkability.

Sending back to the depositor’s address or reusing a recipient can make correlation easier. Explain these tradeoffs to your users, preserve their refund material, and never describe the API as hiding all transaction history.

Build your first integration ↗