# Turmite documentation Every page of https://turmite.com/docs/ in one file. Index: https://turmite.com/llms.txt # Overview > Turmite is a free, self-hosted crypto payment gateway that you run on your own server. Source: https://turmite.com/docs/ Turmite is a crypto payment gateway that you run on your own server. It is free software under the MIT license. Its community maintains it. There is no hosted service. There are no fees and no accounts on turmite.com. One install belongs to one operator and can serve any number of projects (shops). ## How a payment works 1. Your shop sends `POST /v1/invoice` or `POST /v1/payment` to your Turmite server. 2. Turmite reserves a deposit address for the payment. 3. The customer opens the checkout page. The page shows the amount, the address and a QR code. 4. The customer pays from a wallet or an exchange. 5. The worker sees the transaction. The status changes to `confirming`. 6. After the required confirmations, the status changes to `confirmed`, and then to `finished` in the same pass. Your shop ships the order. 7. On chains with a sweep, the worker then moves the funds to your cold wallet. The sweep does not change the payment status. Turmite sends a signed webhook to your shop at each status change. See [Webhooks](/docs/webhooks/) and [Payment statuses](/docs/statuses/). ## Custody per chain Turmite does not hold your funds where the chain allows it. On Bitcoin, Monero and the EVM networks, the destination of the funds is fixed before the customer pays. | Network | Deposit address | Where the funds go | Spending key on the server | |---|---|---|---| | Bitcoin | Derived from your BIP84 account key, watch-only | Directly into your wallet. No sweep | None | | Monero | A subaddress of your wallet, from your address and private view key (view-only) | Directly into your wallet. No sweep | None. The view key can see payments but cannot spend | | Ethereum, Base, Polygon, BNB Smart Chain | A CREATE2 deposit contract per payment | The contract can only flush to the cold wallet fixed in your project's factory | None for deposits. The hot wallet pays gas | | Tron, Solana, TON | A key per payment, derived from the install's deposit seed | Swept to your cold wallet | The deposit seed, encrypted. Only the worker can decrypt it | On Tron, Solana and TON, funds wait at the deposit address until the sweep. A full compromise of the server during that time can reach them. See [Security model](/docs/security-model/). Hot wallets pay network fees and send payouts within the limits that you set. Keep their balance small. See [Payouts](/docs/payouts/). ## Supported networks | Network | Tickers | |---|---| | Bitcoin | `btc` | | Monero | `xmr` | | Ethereum | `eth`, `usdc`, `usdterc20` | | Base | `ethbase`, `usdcbase` | | Polygon | `pol`, `usdcmatic`, `usdtmatic` | | BNB Smart Chain | `bnbbsc`, `usdcbsc`, `usdtbsc` | | Tron | `trx`, `usdttrc20` | | Solana | `sol`, `usdcsol`, `usdtsol` | | TON | `ton` | Each install runs in one mode: `mainnet` or `testnet`. Testnet mode uses Signet, Stagenet, Sepolia, Base Sepolia, Amoy, BSC testnet, Nile, Devnet and the TON testnet. See [Tickers and networks](/docs/networks/). ## Features - A merchant API with the same paths, fields and IPN signature as the common hosted-gateway API. An existing integration changes only the base URL and the keys. See [Migrate from a hosted gateway](/docs/migrate/). - Invoices and payments with a hosted checkout. The checkout works with JavaScript off. - Two kinds of webhooks: a per-payment IPN callback and project endpoints with a timestamped signature. - Payouts from hot wallets on the EVM networks, Solana, Tron and TON, with an address book, limits in USD, and approval with a two-factor code and an emailed code. - Optional payment with any coin through [Trocador](https://trocador.app): the customer pays with a coin that Trocador lists, and an exchange delivers a coin that the project accepts. See [Pay with any coin](/docs/exchange/). - Projects, each with its own API keys, wallets, hot wallets, webhooks, branding and checkout domain. - A dashboard with roles, two-factor authentication and an audit log. The dashboard works with JavaScript off. - A TypeScript SDK (`@turmite/sdk`) and a WooCommerce plugin. ## What Turmite does not do - It does not run a hosted service. You run the server, the database and the wallets. - It does not pay out in fiat money. You receive crypto in your own wallets. - It does not send telemetry. The daily update check reads the public GitHub releases and security advisories of `loruorg/turmite`, and sends only the version. You can turn it off in the dashboard. ## Parts Turmite is one Rust binary and a PostgreSQL database. The binary runs as two processes: | Process | Does | Holds keys | |---|---|---| | `api` | Merchant API, checkout pages, dashboard | No private keys | | `worker` | Chain watchers, confirmations, sweeps, payouts, webhook delivery, update checks | Decrypts hot keys and the deposit seed. Opens no port | See [Architecture](/docs/architecture/). ## For coding agents The docs are available in forms that a coding agent can read: - [`/llms.txt`](/llms.txt): an index of the docs pages. - [`/llms-full.txt`](/llms-full.txt): all docs pages in one file. - Each page as Markdown: add `.md` to its path, for example [`/docs/webhooks.md`](/docs/webhooks.md). - [`/openapi.yaml`](/openapi.yaml): the OpenAPI description of the merchant API. Give the agent `llms-full.txt` and the task, for example: "Add Turmite payments to the checkout of this shop. Verify the IPN signature." ## Next steps - Try it on your computer: [Quick start](/docs/quick-start/). - Install it on a server: [Install on a server](/docs/install/). - Connect your shop: [Accept your first payment](/docs/first-payment/). - Move an existing integration: [Migrate from a hosted gateway](/docs/migrate/). - Read what a server compromise can do: [Security model](/docs/security-model/). # Quick start > Run Turmite on your computer in demo mode with Docker Compose and create a test payment. Source: https://turmite.com/docs/quick-start/ This page starts Turmite on your computer in demo mode. Demo mode uses mock chains. No real coins move and you need no nodes or wallets. Do not use demo mode on a public server. To install Turmite for real payments, see [Install on a server](/docs/install/). ## Requirements - Docker with the Compose plugin (`docker compose version` prints a version). - `openssl` and `curl`. - Port 8080 free on your computer. ## 1. Write the Compose file Make an empty directory and go into it: ```bash mkdir turmite-demo && cd turmite-demo ``` Make a server secret. Compose reads it from `.env`: ```bash echo "TURMITE_SECRET=$(openssl rand -hex 32)" > .env ``` Write this file as `compose.yml`: ```yaml services: postgres: image: postgres:18 environment: POSTGRES_USER: turmite POSTGRES_PASSWORD: turmite POSTGRES_DB: turmite volumes: - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U turmite -d turmite"] interval: 5s timeout: 5s retries: 10 turmite: image: ghcr.io/loruorg/turmite:latest command: ["serve", "--demo"] environment: TURMITE_DATABASE_URL: postgres://turmite:turmite@postgres:5432/turmite TURMITE_SITE_URL: http://localhost:8080 TURMITE_SECRET: ${TURMITE_SECRET} TURMITE_MODE: testnet ports: - "127.0.0.1:8080:8080" depends_on: postgres: condition: service_healthy volumes: pgdata: ``` The image runs the `turmite` binary, so `command` holds only the subcommand. Demo mode needs no `TURMITE_HOT_*` variables and always runs as testnet. Without `TURMITE_SECRET`, demo mode makes a random secret at each start. Then the sealed data, such as two-factor secrets and the IPN secret, is unreadable after a restart. The fixed secret in `.env` prevents this. ## 2. Start it ```bash docker compose up -d docker compose logs -f turmite ``` The `turmite` service runs migrations at start. Wait for the message `demo mode: open http://localhost:8080/dashboard to create the first account`. Then press `Ctrl+C` to stop following the log. Demo mode makes a project called "Demo shop" with a mock wallet on every network. A banner in the dashboard says that the chains are simulated. Check that the server answers: ```bash curl http://localhost:8080/healthz ``` The response is `ok`. ## 3. Create the root user 1. Open `http://localhost:8080/dashboard`. With no user in the database, it opens the setup page. 2. Enter an email address and a password of 12 characters or more, two times. This user is the root user. 3. Submit the form. Turmite signs you in. 4. Optional: open the account page and set up two-factor authentication with an authenticator app. With two-factor on, Turmite asks for a fresh code before sensitive actions, for example to create an API key. On a testnet install, you can do these actions without two-factor. On mainnet, two-factor must be on first. ## 4. Create an API key 1. In the dashboard, open **API keys**. 2. Under **Create a key**, enter a label. Enter your two-factor code if two-factor is on. Click **Create key**. 3. Copy the key. It starts with `tm_`. The dashboard shows it once. Put the key in your shell: ```bash export TM_API_KEY=tm_3f9a... ``` List the tickers that the project accepts: ```bash curl -s http://localhost:8080/v1/currencies -H "x-api-key: $TM_API_KEY" ``` ## 5. Create an invoice ```bash curl -s http://localhost:8080/v1/invoice \ -H "x-api-key: $TM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "price_amount": 25, "price_currency": "usd", "order_id": "ORDER-1001", "order_description": "Demo order", "success_url": "http://localhost:8080/", "cancel_url": "http://localhost:8080/" }' ``` The response has an `invoice_url`. Open it in a browser: 1. Choose a coin. Turmite creates a payment and opens the payment page. 2. The payment page shows the amount, the deposit address, a QR code and a countdown. 3. The status box reloads every 4 seconds. It works with JavaScript off. ## 6. Simulate a payment In demo mode, you add the deposits by hand. The deposit address of a demo payment looks like `mock-bitcoin-00000001`. 1. In the dashboard, open **Payments** and select the payment. 2. Under **Simulate a deposit**, keep the amount. It is the amount due. 3. Set **Confirmations** to `0` and click **Send**. The payment goes to `confirming`. The checkout page shows "Payment received". 4. Open the next payment, or create one, and send the amount with **Confirmations** set to `10`. That is enough on every test network. The payment goes to `confirmed` and then `finished`. The checkout page shows "Paid". Send half of the amount to see `partially_paid`. Then send the rest to see `finished`. You can also create a payment directly, without an invoice: ```bash curl -s http://localhost:8080/v1/payment \ -H "x-api-key: $TM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "price_amount": 25, "price_currency": "usd", "pay_currency": "btc", "order_id": "ORDER-1002", "order_description": "Demo order" }' ``` List the payments and their status: ```bash curl -s "http://localhost:8080/v1/payment?limit=5" -H "x-api-key: $TM_API_KEY" ``` The dashboard shows the same payment under **Payments**. ## 7. Stop and remove it ```bash docker compose down -v ``` The `-v` flag deletes the database volume. ## Next steps - [Install on a server](/docs/install/) for testnet or mainnet. - [Accept your first payment](/docs/first-payment/) to connect a shop. - [Merchant API](/docs/merchant-api/) for every endpoint. # Install on a server > Install Turmite on a Linux server with the install script, set up DNS and TLS, and create the root user. Source: https://turmite.com/docs/install/ This page installs Turmite on a Linux server with Docker Compose and Caddy. Caddy gets the TLS certificate automatically. At the end you have a running server, a root user and two-factor authentication. ## Requirements - A Linux server, `amd64` or `arm64`. The image is published for both. - Ubuntu or Debian, if you want the script to install Docker. On other distributions, install Docker Engine and the Compose plugin first. - `curl` and root access through `sudo`. - A host name for the server, for example `pay.example.com`. - Ports 80 and 443 open to the internet. Caddy uses them to get the certificate. - For mainnet: an SMTP account. Turmite sends payout approval codes by email. Use a server that runs nothing else. A breach of another service on the same server can reach the hot wallets. ## Choose a mode Each install runs in one mode. You set the mode once, at install time. Turmite refuses to start when the mode in `.env` differs from the mode of the database. | Mode | Networks | Use it to | |---|---|---| | `testnet` | Bitcoin Signet, Monero Stagenet, Ethereum Sepolia, Base Sepolia, Polygon Amoy, BNB Smart Chain Testnet, TRON Nile, Solana Devnet, TON Testnet | Test your integration with free test coins | | `mainnet` | The main networks | Take real payments | Install a testnet server first. Install a separate mainnet server when your integration works. ## 1. Point the DNS name at the server 1. Find the public IP address of the server: ```bash curl -4 https://ifconfig.me; echo ``` 2. At your DNS provider, add an `A` record for `pay` with that address. Add an `AAAA` record if the server has IPv6. 3. Wait until the name resolves to the server: ```bash dig +short pay.example.com ``` To put Cloudflare in front of the server, see [Reverse proxy, Cloudflare and firewall](/docs/reverse-proxy/). ## 2. Run the installer Run this on the server: ```bash curl -fsSL https://turmite.com/install.sh | sudo sh -s -- --site-url https://pay.example.com ``` The script asks for the mode and for a hot-wallet passphrase. If Docker is missing, it asks before it installs Docker. | Flag | Default | Meaning | |---|---|---| | `--site-url URL` | asks | The public URL of the server. It can have a path, see [Run under a path](/docs/path-prefix/) | | `--mode MODE` | asks, `mainnet` if you press Enter | `mainnet` or `testnet` | | `--version X.Y.Z` | the latest release | The Turmite version | | `--dir DIR` | `/opt/turmite` | The install directory | | `--yes` | off | Do not ask. Install Docker if it is missing, use `mainnet` if `--mode` is not given, and make a random passphrase | | `--no-start` | off | Write the files, but do not start the stack | | `--upgrade` | off | Upgrade an existing install. See [Upgrades](/docs/upgrades/) | | `--local DIR` | off | Take the files from a checkout of the repository in `DIR`, not from GitHub | Use a passphrase of 12 characters or more. `turmite init` refuses a shorter one. Write it down and store it offline. The worker needs it to decrypt the hot keys and the deposit seed. With `--yes`, the script makes a random passphrase and stores it in `secrets/hot_passphrase`. On mainnet the site URL must start with `https://`. `turmite init` and the server refuse an `http://` site URL in mainnet mode. ## What the installer does 1. It installs Docker Engine and the Compose plugin from Docker's apt repository, if they are missing. 2. It finds the latest release, unless you give `--version`. 3. It writes `docker-compose.yml`, `Caddyfile`, `backup.sh` and `restore.sh` to `/opt/turmite`. 4. It pulls the image `ghcr.io/loruorg/turmite:`. 5. It runs `turmite init` in a container. This command asks for the passphrase and writes `.env` and the `secrets/` directory. 6. It sets `TURMITE_VERSION` in `.env`. 7. It starts the stack and waits up to 2 minutes until the `api` is healthy. Migrations run when the services start. 8. It prints the next steps. The script stops if `/opt/turmite/.env` already exists. Use `--upgrade`, or remove the directory to start again. | Service | Runs | Port | |---|---|---| | `postgres` | PostgreSQL 18, on an internal network | None published | | `api` | `turmite serve --role api` | 8080, internal only | | `worker` | `turmite serve --role worker` | None | | `caddy` | Caddy with automatic TLS | 80 and 443, and UDP 443 for HTTP/3 | The `api` and the `worker` run as a non-root user, with a read-only file system and no Linux capabilities. Only the `worker` mounts `secrets/`. The volumes are `pgdata`, `caddy-data` and `caddy-config`. ## Files | Path | Holds | Back it up | |---|---|---| | `docker-compose.yml` | The services | No. `--upgrade` replaces it and keeps a copy in `backups/` | | `Caddyfile` | The Caddy configuration | Yes, if you change it. `--upgrade` keeps your file | | `.env` | Version, mode, site URL, `TURMITE_SECRET`, `TURMITE_HOT_RECIPIENT`, the paths of the identity and passphrase files, the database password and URL, the Caddy site address. Proxies and SMTP as comments | Yes, offline. Without `TURMITE_SECRET` the sealed data in the database is unreadable | | `secrets/hot-identity.age` | The age identity for hot keys and the deposit seed, encrypted with your passphrase | Yes, offline | | `secrets/hot_passphrase` | The passphrase, so the worker can restart without you | See [Run without a stored passphrase](#run-without-a-stored-passphrase) | | `backup.sh`, `restore.sh` | Database dump and restore | No. `--upgrade` replaces them | | `backups/` | Database dumps | Copy them off the server | All paths are in `/opt/turmite`. `turmite init` writes `.env` and the files in `secrets/` with mode `600`. It gives `secrets/` to the user of the `worker` container. Only root can read `.env`, so run `docker compose`, `backup.sh` and `restore.sh` as root. See [Backups and restore](/docs/backups/). ## 3. Check the install ```bash curl https://pay.example.com/healthz ``` The response is `ok`. If it is not, read the logs: ```bash cd /opt/turmite sudo docker compose ps sudo docker compose logs --tail=100 api worker caddy ``` If Caddy cannot get a certificate, check the DNS record and that ports 80 and 443 are open. See [Troubleshooting](/docs/troubleshooting/). ## 4. Set up email On mainnet, Turmite needs SMTP to send payout approval codes. Without SMTP on mainnet, an action that sends email fails with "Email is not set up. Set TURMITE_SMTP_URL." On testnet, SMTP is optional: Turmite writes each message to the log instead. 1. Open `/opt/turmite/.env` as root. 2. Set the two SMTP variables: ```dotenv TURMITE_SMTP_URL=smtps://user:password@smtp.example.com:465 TURMITE_SMTP_FROM=Turmite ``` Use `smtps://` for TLS on port 465. For STARTTLS on port 587, use `smtp://user:password@smtp.example.com:587?tls=required`. A `smtp://` URL without `?tls=required` sends mail without encryption. Percent-encode special characters in the user name and the password. 3. Apply the change: ```bash cd /opt/turmite && sudo docker compose up -d ``` ## 5. Create the root user 1. Open `https://pay.example.com/dashboard`. With no user in the database, it opens the setup page. 2. Enter your email address and a password of 12 characters or more, two times. 3. Submit the form. This user is the root user. Turmite makes a first project, "My shop", and signs you in. The setup page works only while the database has no users. After that, sign in at `/dashboard/login`. Do this step immediately after the install. Until the root user exists, any visitor can open the setup page. ## 6. Enable two-factor authentication 1. Open the account page in the dashboard. The link is your email address in the sidebar. 2. Click **Set up two-factor**. 3. Scan the QR code with an authenticator app. 4. Enter the code from the app and click **Turn on**. 5. Store the key that the page shows offline, next to your password. When two-factor is on, you need a code to sign in. You also need a fresh code for sensitive actions, for example API keys, wallets, nodes, the IPN secret and payouts. [Dashboard and roles](/docs/dashboard/#actions-that-need-a-fresh-code) lists them. On mainnet, Turmite refuses these actions until two-factor is on. If a user loses the authenticator, an operator with shell access runs `turmite user reset2fa `. See [CLI](/docs/cli/). ## 7. Configure the server Do these steps in this order: 1. [Networks and nodes](/docs/nodes/): enable networks, add RPC nodes, check confirmations. 2. [Wallets per chain](/docs/wallets/): set where each network sends your funds. 3. Hot wallets: add one for each network that needs gas for sweeps or payouts. See [Payouts](/docs/payouts/). 4. [Projects and checkout domains](/docs/projects/): set the branding and the return URL. 5. [Accept your first payment](/docs/first-payment/): create an API key and connect your shop. 6. [Keep the dashboard private](/docs/private-dashboard/). 7. Work through the [Go-live checklist](/docs/go-live/) before you take real payments. ## Run without a stored passphrase By default the passphrase is in `secrets/hot_passphrase`. The worker can then restart without you. To not store it in `secrets/`, run `turmite init` with `--no-store-passphrase` on a new server. On an existing server: 1. Delete the file, and remove the `TURMITE_HOT_PASSPHRASE_FILE` line from `.env`: ```bash sudo shred -u /opt/turmite/secrets/hot_passphrase ``` 2. Give the passphrase in the environment when you start the worker. The compose file passes `TURMITE_HOT_PASSPHRASE` from your shell to the worker. It takes precedence over `TURMITE_HOT_PASSPHRASE_FILE`: ```bash sudo -i cd /opt/turmite read -rs TURMITE_HOT_PASSPHRASE && export TURMITE_HOT_PASSPHRASE docker compose up -d worker unset TURMITE_HOT_PASSPHRASE ``` Docker keeps the variable in the configuration of the container. Root on the server can read it with `docker inspect`. When Docker restarts the same container, the worker has the passphrase. When Compose makes a new container, for example after a change to `.env` or an upgrade, do step 2 again. The worker does not start without the passphrase. It stops with the message "set TURMITE_HOT_PASSPHRASE or TURMITE_HOT_PASSPHRASE_FILE to unlock the hot-wallet identity". While the worker is down, Turmite does not confirm, sweep or notify payments. The `api` process continues to serve the checkout, the dashboard and the merchant API. ## Upgrade or remove To upgrade, see [Upgrades](/docs/upgrades/). To remove Turmite, stop the stack and delete the directory. Back up the database and the secrets first. ```bash cd /opt/turmite && sudo docker compose down -v sudo rm -rf /opt/turmite ``` # Accept your first payment > Create an API key, create an invoice, send the customer to checkout and mark the order paid from the webhook. Source: https://turmite.com/docs/first-payment/ This page connects a shop to Turmite. You create an API key, create an invoice, send the customer to the checkout page and mark the order paid from the webhook. Do it on a testnet install first. ## Before you start - A running install. See [Install on a server](/docs/install/). - At least one network with a node and a wallet. See [Networks and nodes](/docs/nodes/) and [Wallets per chain](/docs/wallets/). - A user with the `project_admin` role. On a mainnet install, the user needs two-factor authentication. The examples use `https://pay.example.com` for Turmite and `https://shop.example.com` for the shop. ## 1. Create an API key 1. In the dashboard, open **API keys**. 2. Under **Create a key**, enter a label and your two-factor code. Click **Create key**. 3. Copy the key. It starts with `tm_`. The dashboard shows it once. 4. Store it in the shop's server environment as `TM_API_KEY`. Keep the key on the server. Never put it in browser code. A key works only for its own project. ## 2. Copy the IPN secret Turmite signs each webhook with the project's IPN secret. 1. In the dashboard, open **Settings**. 2. Under **IPN secret**, enter your two-factor code and click **Show secret**. 3. Copy the secret. 4. Store it in the shop's server environment as `TM_IPN_SECRET`. To replace the secret later, click **Rotate** under **IPN secret**. ## 3. Check the connection ```bash curl https://pay.example.com/v1/status ``` The response is `{"message":"OK"}`. Then list the tickers that your project accepts: ```bash curl https://pay.example.com/v1/currencies -H "x-api-key: $TM_API_KEY" ``` ## 4. Create an invoice Create an invoice when the customer checks out. The customer chooses the coin on the Turmite checkout page. ```bash curl https://pay.example.com/v1/invoice \ -H "x-api-key: $TM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "price_amount": 25, "price_currency": "usd", "order_id": "ORDER-1001", "order_description": "Order 1001", "ipn_callback_url": "https://shop.example.com/turmite/ipn", "success_url": "https://shop.example.com/orders/ORDER-1001", "cancel_url": "https://shop.example.com/cart" }' ``` The same request in Node.js 18 or later: ```js const res = await fetch('https://pay.example.com/v1/invoice', { method: 'POST', headers: { 'x-api-key': process.env.TM_API_KEY, 'content-type': 'application/json', }, body: JSON.stringify({ price_amount: 25, price_currency: 'usd', order_id: 'ORDER-1001', order_description: 'Order 1001', ipn_callback_url: 'https://shop.example.com/turmite/ipn', success_url: 'https://shop.example.com/orders/ORDER-1001', cancel_url: 'https://shop.example.com/cart', }), }); if (!res.ok) throw new Error(`Turmite returned ${res.status}`); const invoice = await res.json(); ``` In PHP: ```php $ch = curl_init('https://pay.example.com/v1/invoice'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => [ 'x-api-key: ' . getenv('TM_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'price_amount' => 25, 'price_currency' => 'usd', 'order_id' => 'ORDER-1001', 'order_description' => 'Order 1001', 'ipn_callback_url' => 'https://shop.example.com/turmite/ipn', 'success_url' => 'https://shop.example.com/orders/ORDER-1001', 'cancel_url' => 'https://shop.example.com/cart', ]), ]); $invoice = json_decode(curl_exec($ch), true); ``` In Python: ```python import os, requests r = requests.post( "https://pay.example.com/v1/invoice", headers={"x-api-key": os.environ["TM_API_KEY"]}, json={ "price_amount": 25, "price_currency": "usd", "order_id": "ORDER-1001", "order_description": "Order 1001", "ipn_callback_url": "https://shop.example.com/turmite/ipn", "success_url": "https://shop.example.com/orders/ORDER-1001", "cancel_url": "https://shop.example.com/cart", }, timeout=20, ) r.raise_for_status() invoice = r.json() ``` The response has the checkout link in `invoice_url`: ```json { "id": "4522625843", "token_id": "k3J9xQ2vT8mZ5wR1nB7cY4pL", "order_id": "ORDER-1001", "order_description": "Order 1001", "price_amount": "25", "price_currency": "usd", "pay_currency": null, "ipn_callback_url": "https://shop.example.com/turmite/ipn", "invoice_url": "https://pay.example.com/i/k3J9xQ2vT8mZ5wR1nB7cY4pL", "success_url": "https://shop.example.com/orders/ORDER-1001", "cancel_url": "https://shop.example.com/cart", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:00:00.000Z", "expiration_estimate_date": "2026-10-24T12:00:00.000Z" } ``` `id` is numeric text and `price_amount` is a decimal string. Save `id` with the order. ## 5. Send the customer to checkout Redirect the customer to `invoice_url`. The checkout page: 1. Shows the amount and the coins that the project accepts. 2. Creates a payment when the customer chooses a coin. 3. Shows the deposit address, the exact amount, a QR code and a countdown. 4. Updates the status every few seconds. It works with JavaScript off. 5. Shows a button back to `success_url` when the payment is complete. The return button is only a link. Do not mark the order paid when the customer visits `success_url`. Use the webhook in step 6. See [Checkout](/docs/checkout/) for branding and the return URL. ### Use your own payment page To choose the coin in your shop, call `POST /v1/payment` with `pay_currency`. The response has `pay_address` and `pay_amount`. Your shop shows them to the customer, or sends the customer to `payment_url`, the hosted payment page for that coin. See [Merchant API](/docs/merchant-api/). ## 6. Handle the webhook Turmite sends a `POST` to `ipn_callback_url` at each status change. The body is the payment object. The `x-turmite-sig` header holds the hex HMAC-SHA512 of the body with keys sorted, signed with the IPN secret. An example body: ```json { "payment_id": 5524759814, "invoice_id": 4522625843, "payment_status": "finished", "pay_address": "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk", "payin_extra_id": null, "price_amount": 25, "price_currency": "usd", "pay_amount": 0.00031245, "actually_paid": 0.00031245, "pay_currency": "btc", "order_id": "ORDER-1001", "order_description": "Order 1001", "purchase_id": "6084744717", "outcome_amount": 0.00031245, "outcome_currency": "btc", "network": "btc", "network_precision": 8, "smart_contract": null, "payin_hash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", "payout_hash": null, "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:41:07.000Z", "expiration_estimate_date": "2026-10-23T13:00:00.000Z" } ``` In the webhook, `payment_id` and `invoice_id` are numbers and `purchase_id` is a string. Amounts are numbers. `network` is a lowercase code. A handler in Node.js with Express and the [TypeScript SDK](/docs/sdk/). `verifyIpnSignature` makes the key-sorted JSON from the raw body and keeps the text of each number: ```js import express from 'express'; import { isPaid, verifyIpnSignature } from '@turmite/sdk'; const app = express(); app.post('/turmite/ipn', express.raw({ type: 'application/json' }), async (req, res) => { const ok = await verifyIpnSignature(req.body, req.get('x-turmite-sig'), process.env.TM_IPN_SECRET); if (!ok) return res.sendStatus(401); const payment = JSON.parse(req.body.toString('utf8')); if (isPaid(payment.payment_status)) { await markOrderPaid(payment.order_id, payment.payment_id); // must be idempotent } res.sendStatus(200); }); ``` PHP, Python, Go and Ruby versions, and the timestamped `x-turmite-signature`, are in [Webhooks](/docs/webhooks/). Rules for the handler: - Verify the signature before you read any field. - Ship the order only on `finished`. - Check that `order_id` and `invoice_id` match an order that you created. - Make the handler idempotent. The same status can arrive more than once. - Respond with a `2xx` status fast. Do slow work, for example emails, after the response. - Do not depend on the order of deliveries. Turmite does not guarantee it. Use `payment_status` and `updated_at` from the body. - Turmite waits 30 seconds for each response. A slower response counts as a failure and Turmite retries. See [Payment statuses](/docs/statuses/) for every status and what to do. ## 7. Check the status without a webhook If your shop missed a webhook, ask for the payment: ```bash curl https://pay.example.com/v1/payment/5524759814 -H "x-api-key: $TM_API_KEY" ``` Use this as a fallback only. Do not poll in a tight loop. ## 8. Test on testnet 1. On a testnet install, enable one network and set its wallet. Base Sepolia or Solana Devnet are fast. 2. Get test coins from a public faucet for that network. On Solana Devnet you can run `solana airdrop 1
--url devnet`. 3. Create an invoice and open `invoice_url`. 4. Pay the exact amount from your test wallet. 5. Watch the payment in the dashboard under **Payments**. 6. Check that your handler received each status change up to `finished`. On testnet, `ipn_callback_url` can be an `http://` URL or a private address. On mainnet it must be a public `https://` URL. To test the handler without real coins, run `turmite serve --demo` on your computer. Create a payment, open it in the dashboard and use **Simulate a deposit**. Turmite then sends the IPN as for a real deposit. See [Quick start](/docs/quick-start/). ## Next steps - [Webhooks](/docs/webhooks/): project endpoints, retries and the second signature. - [Payouts](/docs/payouts/): refunds and supplier payments from the hot wallet. - [Go-live checklist](/docs/go-live/): before you switch the shop to mainnet. # Migrate from a hosted gateway > Move an integration of the common hosted-gateway API to Turmite by changing the base URL, the API key and the IPN secret. Source: https://turmite.com/docs/migrate/ The Turmite merchant API uses the same paths, fields, statuses and IPN signature as the common hosted-gateway API. Your existing integration keeps its code. You change the base URL, the API key and the IPN secret. ## What stays the same | Part | Detail | |---|---| | Paths | `/v1/payment`, `/v1/invoice`, `/v1/invoice-payment`, `/v1/currencies`, `/v1/estimate`, `/v1/min-amount`, `/v1/payout` and the others in [Merchant API](/docs/merchant-api/) | | Authentication | The `x-api-key` header | | Request fields | `price_amount`, `price_currency`, `pay_currency`, `order_id`, `order_description`, `ipn_callback_url`, `success_url`, `cancel_url` | | Payment object | `payment_id`, `payment_status`, `pay_address`, `pay_amount`, `actually_paid`, `outcome_amount` and the other fields | | Statuses | `waiting`, `confirming`, `confirmed`, `partially_paid`, `finished`, `refunded`, `expired`. `sending` and `failed` are in the API, but Turmite does not set them | | Tickers | The same codes, for example `btc`, `usdttrc20`, `usdcsol`. See [Tickers and networks](/docs/networks/) | | IPN body | The flat payment object, sent to `ipn_callback_url` | | IPN signature | Hex HMAC-SHA512 of the JSON body with keys sorted, signed with the IPN secret | | Errors | `{"status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "..."}` | | Payouts | `POST /v1/auth` for a JWT, then `POST /v1/payout`, then `POST /v1/payout/{batch_id}/verify` | ## What changes | Setting | Before | After | |---|---|---| | Base URL | The gateway's API URL | Your site URL, for example `https://pay.example.com` | | API key | The gateway's key | A key from your dashboard. It starts with `tm_` | | IPN secret | The gateway's IPN secret | The IPN secret of your Turmite project | | Signature header | The gateway's header name | `x-turmite-sig` by default. You can change it | | Payout login | Your gateway account | The email and password of a Turmite dashboard user who is a project admin | | Payout verification code | A code from the gateway | The current two-factor (TOTP) code of that dashboard user | The paths start with `/v1`. If your configuration stores the base URL with `/v1` at the end, set it to `https://pay.example.com/v1`. If it stores the host only, set it to `https://pay.example.com`. ### Keep your signature header Your webhook handler reads the signature from one header name. You have two options: 1. In the dashboard, open **Settings**. On the **General** tab, set **IPN signature header** to the header name that your code reads. Your handler needs no change. 2. Change your handler to read `x-turmite-sig`. Turmite also sends `x-turmite-timestamp` and `x-turmite-signature`. That signature covers the timestamp and the raw body, so a receiver can reject replays. You can move to it later. See [Webhooks](/docs/webhooks/). ## Before you switch Turmite is self-hosted. Before your shop can use it, you set up the parts that a hosted gateway runs for you. 1. Install a server. See [Install on a server](/docs/install/). 2. Add RPC nodes for each network that you accept. See [Networks and nodes](/docs/nodes/). 3. Set a wallet for each network. Funds go to these wallets. See [Wallets per chain](/docs/wallets/). 4. Enable the tickers that your shop sends as `pay_currency`. 5. For payouts, add and fund a hot wallet. See [Payouts](/docs/payouts/). ## Checklist - [ ] A testnet install works and a test payment reached `finished`. - [ ] Every `pay_currency` that your shop sends is in `GET /v1/currencies`. - [ ] Your integration uses only the endpoints and fields in [Merchant API](/docs/merchant-api/). - [ ] The base URL points at your server and keeps the `/v1` rule above. - [ ] The API key is a new `tm_` key, stored on the shop server only. - [ ] The IPN secret is the Turmite project secret. - [ ] The signature header name matches your handler, or your handler reads `x-turmite-sig`. - [ ] `ipn_callback_url` is an `https://` URL that answers directly. Mainnet refuses private addresses and does not follow redirects. - [ ] Your handler ships only on `finished` and is idempotent. - [ ] If you use payouts: a project admin with two-factor authentication exists for the API, the hot wallet is funded, the address book has your destinations and the limits are set. - [ ] You did one small real payment on each network on the mainnet install. ## Differences to know - You run the server, the database and the nodes. If the server is down, customers cannot open the checkout. When the worker starts again, it finds the deposits made during the downtime. - Funds go to your own wallets. On Bitcoin and Monero they arrive directly. On other chains a deposit address forwards them to your cold wallet. - Turmite does not convert. The customer's coin goes to your wallet, and `outcome_currency` is always `pay_currency`. - `GET /v1/balance` is a total of credited payments. It is not a balance that Turmite holds for you. - Turmite supports only the networks and tickers in [Tickers and networks](/docs/networks/). A ticker that is not enabled returns `400`. - `payin_extra_id` is always `null`. No supported network needs a deposit memo. - `POST /v1/payment` returns `payment_id` as a string. `GET /v1/payment` and webhooks send it as a number. Store it as a string. - An endpoint that Turmite does not have returns `404` with the code `NOT_FOUND`. Turmite ignores request fields that it does not know. - Payouts work from hot wallets on the EVM networks, Solana, Tron and TON. Bitcoin and Monero have no hot wallet. - API payouts can only go to addresses in the project's address book. New API payouts wait in `pending-otp-verification` until the verify call. Payouts above the project limits also need approval in the dashboard with an emailed code. The default auto-approve amount is 0, so every payout needs an approval until you change it. - There is no separate sandbox host. Use an install with `TURMITE_MODE=testnet`, or `turmite serve --demo` on your computer. ## Switch in steps 1. Point a staging copy of your shop at a testnet install. 2. Run your existing tests. Pay a few test orders with testnet coins. 3. Install the mainnet server and set its wallets and nodes. 4. Change the base URL, the API key and the IPN secret in production. 5. Pay one small real order on each network that you accept. Payments that you created before the switch stay with the old gateway. Keep the old webhook handler and keys until those payments are final. To roll back, change the three settings back. # Merchant API > Use the merchant API to create payments and invoices, read their status and send payouts. Source: https://turmite.com/docs/merchant-api/ Your shop's server calls the merchant API to create payments and invoices and to read their status. The API keeps the paths, fields, statuses and IPN signature of the common hosted-gateway API. The [API reference](/docs/api-reference/) lists every field and schema. ## Base URL The base URL is your site URL (`TURMITE_SITE_URL`). Every path starts with `/v1/`. | Site URL | Example request | |---|---| | `https://pay.example.com` | `https://pay.example.com/v1/payment` | | `https://example.com/pay` | `https://example.com/pay/v1/payment` | A project's checkout domain also serves the merchant API. A shop can use its own checkout domain as the base URL. See [Projects and checkout domains](/docs/projects/). ## Authentication Send the project API key in the `x-api-key` header. Keys start with `tm_`. Each key belongs to one project, and the API only shows data of that project. 1. Open the dashboard and select the project. 2. Open **API keys**. 3. Under **Create a key**, enter a label and a fresh two-factor code. Then click **Create key**. 4. Copy the key. The dashboard shows it one time. You need the `project_admin` role to create a key. A `developer` can revoke a key. Turmite stores only a SHA-256 hash of each key. Keep the key on your server. Do not put it in browser code or in a mobile app. ```bash curl https://pay.example.com/v1/status curl -H "x-api-key: $TM_API_KEY" https://pay.example.com/v1/merchant/coins ``` The payout endpoints also need a short-lived token from `POST /v1/auth`. See [Payouts](#payouts). ## Requests and responses - Send JSON bodies with `Content-Type: application/json`. Turmite ignores fields that it does not know. - Tickers (`pay_currency`, `currency`) are lowercase, for example `btc` or `usdttrc20`. See [Tickers and networks](/docs/networks/). - Request amounts can be a JSON number or a decimal string, for example `25` or `"25.00"`. - `POST /v1/payment`, `POST /v1/invoice`, `POST /v1/invoice-payment` and `POST /v1/payout` answer `201` with the new object. A retry that returns an existing object also gets `201`. - Payment amounts in responses are JSON numbers. A whole amount is an integer: `25`, not `25.0`. An amount below 0.00001 can be in exponent form, for example `1e-6`. Invoice amounts, payout amounts and `estimated_amount` are decimal strings. - Timestamps are ISO 8601 in UTC, with milliseconds. - Each client IP address can send 600 requests per minute. Above that, the API returns `429`. - An error has this shape. See [Errors](/docs/errors/). ```json {"status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "Currency `btc` is not enabled for this project. Add a Bitcoin wallet in the dashboard."} ``` ### Price currencies and rates `price_currency` is a currency code that the rate source knows, for example `usd`, `eur` or `btc`. Responses write it in lowercase. Turmite gets rates from CoinGecko. When CoinGecko fails, it uses Coinbase. It keeps each rate for 60 seconds. USDT and USDC count as 1 USD. When `price_currency` is the ticker or the symbol of `pay_currency`, for example `usdt` for `usdttrc20`, Turmite does not convert the price. When no source gives a rate, the request gets `503` with the code `UNAVAILABLE`. Try again after a minute. ## Endpoints | Method | Path | Auth | Use | |---|---|---|---| | GET | `/v1/status` | none | Health of the API | | GET | `/v1/currencies` | key optional | Enabled tickers | | GET | `/v1/full-currencies` | key optional | Details per ticker | | GET | `/v1/merchant/coins` | key | Tickers enabled for the project | | GET | `/v1/min-amount` | none | Smallest payment for a ticker | | GET | `/v1/estimate` | none | Price conversion | | POST | `/v1/payment` | key | Create a payment | | GET | `/v1/payment/{payment_id}` | key | Read one payment | | GET | `/v1/payment` | key | List payments | | POST | `/v1/invoice` | key | Create an invoice with a checkout link | | POST | `/v1/invoice-payment` | key | Create a payment for an invoice | | GET | `/v1/balance` | key | Credited and pending amounts per ticker | | POST | `/v1/auth` | none | Get a token for payouts | | POST | `/v1/payout` | key and token | Create a payout batch | | POST | `/v1/payout/{batch_id}/verify` | key and token | Confirm a batch with a two-factor code | | GET | `/v1/payout/{batch_id}` | key | Read one batch | | GET | `/v1/payout` | key and token | List payouts | | POST | `/v1/payout/validate-address` | key | Check a destination address | | GET | `/v1/payout/fee` | key | Estimate the network fee | "Key optional" endpoints answer for the key's project when you send a key, and for the whole install when you do not. A wrong key gets `401`. ## Status and currencies `GET /v1/status` returns `{"message":"OK"}` when the API is up. For load balancer checks, use `/healthz`. It returns `ok`. `GET /v1/currencies` returns the tickers that the key's project accepts. A project accepts a ticker when the ticker and its network are enabled and the project has a wallet on that network. Without a key, the list has the tickers that at least one project accepts. ```bash curl -H "x-api-key: $TM_API_KEY" https://pay.example.com/v1/currencies ``` ```json {"currencies": ["btc", "xmr", "eth", "usdc", "usdterc20", "usdcbase", "usdttrc20", "sol"]} ``` `GET /v1/full-currencies` returns every enabled ticker of the install, with details. ```json { "currencies": [ { "id": 7, "code": "USDTTRC20", "name": "Tether USD", "enable": true, "wallet_regex": null, "priority": 7, "extra_id_exists": false, "extra_id_regex": null, "logo_url": null, "track": false, "cg_id": "tether", "is_maxlimit": false, "network": "tron", "smart_contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "network_precision": "6", "ticker": "usdttrc20" } ] } ``` `ticker` is the value to send as `pay_currency`. `code` is the ticker in upper case. `enable` is true when the project accepts the ticker. `network_precision` is the number of decimals, as a string. `smart_contract` is `null` for a native coin. `wallet_regex`, `extra_id_regex` and `logo_url` are always `null`. `GET /v1/merchant/coins` returns the tickers that the project accepts. ```json {"selectedCurrencies": ["btc", "usdcbase", "usdttrc20"]} ``` ## Estimates `GET /v1/min-amount` returns the smallest payment for a ticker. | Parameter | Required | Description | |---|---|---| | `currency_from` | yes | The ticker, for example `btc` | | `currency_to` | no | Copied to the response. The default is `currency_from` | | `fiat_equivalent` | no | A fiat code. The response then has `fiat_equivalent`, the minimum in that currency, when a rate is available | ```bash curl "https://pay.example.com/v1/min-amount?currency_from=btc&fiat_equivalent=usd" ``` ```json {"currency_from": "btc", "currency_to": "btc", "min_amount": 1e-6, "fiat_equivalent": 0.07} ``` When the operator set no minimum for the ticker, `min_amount` is a default that depends on the decimals of the ticker. `POST /v1/payment` refuses an amount below `min_amount` with `400` and the code `INVALID_REQUEST_PARAMS`. `GET /v1/estimate` converts a price into a ticker amount at the current rate. Turmite rounds the amount up to the decimals of the ticker. | Parameter | Required | Description | |---|---|---| | `amount` | yes | The price | | `currency_from` | yes | The price currency, for example `usd` | | `currency_to` | yes | The ticker, for example `btc` | ```bash curl "https://pay.example.com/v1/estimate?amount=25¤cy_from=usd¤cy_to=btc" ``` ```json {"currency_from": "usd", "amount_from": 25, "currency_to": "btc", "estimated_amount": "0.00041235"} ``` ## Payments or invoices Turmite has two ways to take a payment. | Use | Create | The customer sees | |---|---|---| | Your shop shows the address and the amount on its own page | `POST /v1/payment` | Your page | | Turmite shows the checkout | `POST /v1/invoice` | The invoice page at `invoice_url`, then the payment page | The checkout pages work with JavaScript off. See [Checkout](/docs/checkout/). ## Create a payment `POST /v1/payment` creates a payment with its own deposit address. | Field | Required | Description | |---|---|---| | `price_amount` | yes | Price in `price_currency`. It must be more than 0 | | `price_currency` | yes | Currency of the price, for example `usd` | | `pay_currency` | yes | Ticker the customer pays with, for example `btc`. The project must accept it | | `pay_amount` | no | A fixed amount in `pay_currency`. Without it, Turmite converts `price_amount` at the current rate | | `order_id` | no | Your order number, up to 255 characters. Webhooks include it | | `order_description` | no | Text for your records, up to 1000 characters | | `ipn_callback_url` | no | URL that receives a webhook at each status change | | `success_url` | no | Page for the "Return to store" button after payment | | `cancel_url` | no | Page for the return button after the payment expires | | `purchase_id` | no | Your purchase reference, up to 255 characters. Without it, Turmite makes a 10-digit one | | `idempotency_key` | no | Up to 255 characters. A retry with the same key returns the first payment. You can send it in the `Idempotency-Key` header instead | The three URLs must be `https` URLs without a user name or password. A testnet install also accepts `http`. If the project has **Allowed return hosts** under **Settings** → **Checkout**, `success_url` and `cancel_url` must use one of these hosts. Send `idempotency_key` when your shop can retry the request, for example after a timeout. Use one key per order. A request with a known key returns the first payment, even when other fields are different. ```bash curl -X POST https://pay.example.com/v1/payment \ -H "x-api-key: $TM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "price_amount": 25, "price_currency": "usd", "pay_currency": "btc", "order_id": "ORDER-1001", "order_description": "Order 1001", "ipn_callback_url": "https://shop.example.com/turmite/ipn", "idempotency_key": "ORDER-1001" }' ``` The response is `201` with a [payment object](#the-payment-object) in the status `waiting`. Show `pay_address` and `pay_amount` to the customer on your own page, or send the customer to `payment_url`, the hosted payment page. Save `payment_id` with the order. ```json { "payment_id": "5524759814", "invoice_id": null, "payment_status": "waiting", "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "payin_extra_id": null, "price_amount": 25, "price_currency": "usd", "pay_amount": 0.00041235, "actually_paid": 0, "pay_currency": "btc", "order_id": "ORDER-1001", "order_description": "Order 1001", "purchase_id": "4709931082", "outcome_amount": null, "outcome_currency": "btc", "network": "btc", "network_precision": 8, "smart_contract": null, "payin_hash": null, "payout_hash": null, "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:00:00.000Z", "expiration_estimate_date": "2026-10-23T13:00:00.000Z", "parent_payment_id": null, "type": "crypto2crypto", "time_limit": null, "burning_percent": null, "payment_extra_ids": null, "amount_received": 0.00041235, "actually_paid_at_fiat": 0, "fee": {"currency": "btc", "depositFee": 0, "withdrawalFee": 0, "serviceFee": 0} } ``` In this response, `payment_id` and `purchase_id` are strings. See [The payment object](#the-payment-object) for the types in the other responses. A payment expires after the project's payment window, 60 minutes by default. Change it in **Settings** under **Payment window (minutes)**, from 5 to 10080. When the network has no free deposit address, or no node is connected for it, the request gets `503` with `Retry-After: 30`. Send it again after 30 seconds. ## Create an invoice `POST /v1/invoice` creates an invoice and returns `invoice_url`. Send the customer to that URL. The customer selects a coin, and Turmite creates the payment. | Field | Required | Description | |---|---|---| | `price_amount` | yes | Price in `price_currency` | | `price_currency` | yes | Currency of the price | | `pay_currency` | no | Ticker to use. Set it to skip the coin selection on the invoice page | | `order_id` | no | Your order number | | `order_description` | no | Text for your records | | `ipn_callback_url` | no | URL that receives the webhooks of the invoice's payments | | `success_url` | no | Page for the "Return to store" button | | `cancel_url` | no | Page for the return button after the payment expires | The limits and URL rules are the same as for `POST /v1/payment`. The payment that the customer starts copies `order_id`, `order_description`, `ipn_callback_url`, `success_url` and `cancel_url` from the invoice. ```bash curl -X POST https://pay.example.com/v1/invoice \ -H "x-api-key: $TM_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "price_amount": 25, "price_currency": "usd", "order_id": "ORDER-1001", "order_description": "Order 1001", "ipn_callback_url": "https://shop.example.com/turmite/ipn", "success_url": "https://shop.example.com/orders/1001", "cancel_url": "https://shop.example.com/cart" }' ``` ```json { "id": "4522625843", "token_id": "k3J9xQ2vT8mZ5wR1nB7cY4pL", "order_id": "ORDER-1001", "order_description": "Order 1001", "price_amount": "25", "price_currency": "usd", "pay_currency": null, "ipn_callback_url": "https://shop.example.com/turmite/ipn", "invoice_url": "https://pay.example.com/i/k3J9xQ2vT8mZ5wR1nB7cY4pL", "success_url": "https://shop.example.com/orders/1001", "cancel_url": "https://shop.example.com/cart", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:00:00.000Z", "expiration_estimate_date": "2026-10-24T12:00:00.000Z" } ``` `id` is numeric text. `price_amount` is a decimal string. An invoice does not change, so `updated_at` is always `created_at`. An invoice stays open for the project's invoice window, 1440 minutes (24 hours) by default. Change it in **Settings** under **Invoice window (minutes)**, from 5 to 43200. If the project has a checkout domain, `invoice_url` uses it. ### Create a payment for an invoice `POST /v1/invoice-payment` creates the payment for an invoice from your own coin picker. Send the invoice `id` as `iid` and the ticker as `pay_currency`. `purchase_id` is optional. The response is a payment object with `invoice_id` set. The payment gets the price, the order and the URLs of the invoice. If the invoice already has an open payment in that ticker, the response is that payment. An expired invoice gets `400`. ```bash curl -X POST https://pay.example.com/v1/invoice-payment \ -H "x-api-key: $TM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"iid": "4522625843", "pay_currency": "usdttrc20"}' ``` ## Read payments `GET /v1/payment/{payment_id}` returns one payment. A payment of another project gets `404`. ```bash curl -H "x-api-key: $TM_API_KEY" https://pay.example.com/v1/payment/5524759814 ``` `GET /v1/payment` returns a page of payments. | Parameter | Description | |---|---| | `limit` | Payments per page, from 1 to 500. The default is 10 | | `page` | Page number. The first page is 0 | | `sortBy` | `created_at` (default), `updated_at`, `payment_id`, `payment_status`, `pay_address`, `price_amount`, `price_currency`, `pay_amount`, `actually_paid`, `pay_currency`, `outcome_amount`, `outcome_currency`, `order_id`, `order_description` or `purchase_id` | | `orderBy` | `asc` or `desc`. The default is `desc` | | `dateFrom`, `dateTo` | Range of `created_at`. A date (`2026-10-01`) or a date-time (`2026-10-31T23:59:59Z`). A date in `dateTo` includes the full day | | `payment_status` | Only payments with this status | | `order_id` | Only payments with this order ID | | `invoiceId` | Only payments of this invoice | ```bash curl -H "x-api-key: $TM_API_KEY" \ "https://pay.example.com/v1/payment?limit=10&page=0&sortBy=created_at&orderBy=desc" ``` ```json {"data": [{"payment_id": 5524759814, "payment_status": "finished"}], "limit": 10, "page": 0, "pagesCount": 1, "total": 1} ``` The example shows two fields of each payment. Each item in `data` is a full payment object. Use webhooks to learn about status changes. Use these endpoints to check a status again or to reconcile. ## Balance `GET /v1/balance` returns two amounts for each ticker that has payments in the project. | Field | Value | |---|---| | `amount` | The sum of `outcome_amount` of the project's payments, refunded payments excluded | | `pendingAmount` | The amount still due on open payments (`waiting`, `confirming`, `partially_paid`, `confirmed`): `pay_amount` minus the credited amount | ```json {"btc": {"amount": 0.0153, "pendingAmount": 0.0012}, "usdttrc20": {"amount": 1250.5, "pendingAmount": 0}} ``` The balance is a total of payments. It is not the balance of a wallet. Payouts do not decrease it. A payment that you mark as refunded leaves it. ## Payouts Payouts send funds from the project's hot wallet. Read [Payouts](/docs/payouts/) for the setup, the limits and the approval rules. 1. `POST /v1/auth` with the email address and password of a dashboard user returns a token. The token is valid for 5 minutes. 2. `POST /v1/payout` with the API key and the token creates a batch. The user must be a project admin. Each address must be in the project's address book. New payouts have the status `pending-otp-verification`. 3. `POST /v1/payout/{batch_id}/verify` with the user's current two-factor code releases the batch. The project limits then set each payout to `pending` or `pending-approval`. The full example is in [Create a payout with the API](/docs/payouts/#create-a-payout-with-the-api). `POST /v1/auth` accepts 10 attempts per minute for each IP address and for each email address. ## The payment object | Field | Description | |---|---| | `payment_id` | ID of the payment | | `invoice_id` | ID of the invoice as a number, or `null` | | `payment_status` | Status. See [Payment statuses](/docs/statuses/) | | `pay_address` | Deposit address for this payment | | `payin_extra_id` | Always `null`. No supported network needs a deposit memo | | `price_amount` | Price in `price_currency` | | `price_currency` | Currency of the price | | `pay_amount` | Amount the customer must send, in `pay_currency` | | `actually_paid` | Amount of all deposits seen so far, confirmed or not | | `pay_currency` | Ticker the customer pays with | | `order_id` | Your order number | | `order_description` | Your description | | `purchase_id` | Your purchase reference, or a 10-digit number that Turmite makes | | `outcome_amount` | Confirmed amount credited to the project, or `null` before the first credit. Sweep fees are not subtracted | | `outcome_currency` | Same as `pay_currency` | | `network` | Network code: `btc`, `xmr`, `eth`, `base`, `polygon`, `bsc`, `tron`, `sol` or `ton` | | `network_precision` | Decimals of `pay_currency` | | `smart_contract` | Token contract, or `null` for a native coin | | `payin_hash` | Hash of the latest deposit transaction, or `null` | | `payout_hash` | Hash of the latest sweep from the deposit address to your wallet, or `null`. Always `null` on Bitcoin and Monero | | `payment_url` | Hosted payment page, `/pay/` | | `created_at` | Time of creation | | `updated_at` | Time of the last change | | `expiration_estimate_date` | End of the payment window | | `amount_received` | Amount the project gets in `outcome_currency`: `outcome_amount` after the first credit, `pay_amount` before it | | `actually_paid_at_fiat` | `actually_paid` in `price_currency` at the rate of the payment. For a fixed `pay_amount`, the rate is `price_amount` / `pay_amount` | | `fee` | `{currency, depositFee, withdrawalFee, serviceFee}`. Turmite takes no fee: `depositFee` and `serviceFee` are `0`. `withdrawalFee` is the network fee of the sweeps to your wallet | | `type` | Always `crypto2crypto` | | `parent_payment_id`, `time_limit`, `burning_percent`, `payment_extra_ids` | Always `null` | The types of two IDs depend on the response. Typed clients of the common hosted-gateway API expect these types. | Response | `payment_id` | `purchase_id` | |---|---|---| | `POST /v1/payment`, `POST /v1/invoice-payment` | String | String | | `GET /v1/payment/{payment_id}`, `GET /v1/payment` | Number | Number when it is a plain integer up to 2^53 - 1, else string | | Webhooks | Number | String | The payment object keeps the field names of the common hosted-gateway API. `network_precision`, `smart_contract`, `payin_hash`, `payout_hash` and `payment_url` are extra fields. The checkout page uses a random token in its URL. The numeric `payment_id` never appears in a checkout link. ## Statuses in short Ship the order when the status is `finished`. The full list and the order of statuses are in [Payment statuses](/docs/statuses/). Webhooks tell you about each change. See [Webhooks](/docs/webhooks/). # API reference > Every merchant endpoint, request field, response and webhook payload, generated from openapi.yaml. Source: https://turmite.com/docs/api-reference/ This page is generated from [`openapi.yaml`](https://github.com/loruorg/turmite/blob/main/openapi.yaml) in the repository. The file is an OpenAPI 3.1 document. Load it into your own tools to generate a client or to test requests. The base URL is your site URL, for example `https://pay.example.com`. Send the project API key in the `x-api-key` header. Payout endpoints also need a JWT from `POST /v1/auth` in the `Authorization: Bearer` header. The [Merchant API guide](/docs/merchant-api/) explains each flow with examples. ## Payments Create payments and read their status. ### GET /v1/payment List payments Auth: `apiKey` | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `limit` | query | `integer` | no | Payments per page. Turmite changes a value below 1 to 1 and a value above 500 to 500. | | `page` | query | `integer` | no | Page number. The first page is 0. | | `sortBy` | query | `string` | no | One of: `payment_id`, `payment_status`, `pay_address`, `price_amount`, `price_currency`, `pay_amount`, `actually_paid`, `pay_currency`, `order_id`, `order_description`, `purchase_id`, `outcome_amount`, `outcome_currency`, `created_at`, `updated_at`. | | `orderBy` | query | `string` | no | One of: `asc`, `desc`. | | `dateFrom` | query | `string` | no | Earliest `created_at`. A date or a date-time. | | `dateTo` | query | `string` | no | Latest `created_at`. A date or a date-time. | | `invoiceId` | query | `integer (int64)` | no | Only the payments of this invoice. | | `payment_status` | query | `string` | no | Only the payments with this status. One of: `waiting`, `confirming`, `confirmed`, `sending`, `partially_paid`, `finished`, `failed`, `refunded`, `expired`. | | `order_id` | query | `string` | no | Only the payments with this `order_id`. | Response `200`: One page of payments. ```json { "data": [ { "invoice_id": 0, "payment_status": "waiting", "pay_address": "string", "payin_extra_id": "string", "price_amount": 0, "price_currency": "usd", "pay_amount": 0, "actually_paid": 0, "pay_currency": "btc", "order_id": "string", "order_description": "string", "outcome_amount": 0, "outcome_currency": "string", "network": "btc", "network_precision": 0, "smart_contract": "string", "payin_hash": "string", "payout_hash": "string", "payment_url": "string", "created_at": "2026-10-23T12:00:00Z", "updated_at": "2026-10-23T12:00:00Z", "expiration_estimate_date": "2026-10-23T12:00:00Z", "parent_payment_id": null, "type": "crypto2crypto", "time_limit": null, "burning_percent": null, "payment_extra_ids": null, "amount_received": 0, "actually_paid_at_fiat": 0, "fee": { "currency": "btc", "depositFee": 0, "withdrawalFee": 0, "serviceFee": 0 }, "payment_id": 0, "purchase_id": 6084815412 } ], "limit": 0, "page": 0, "pagesCount": 0, "total": 0 } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### POST /v1/payment Create a payment Reserves a deposit address and fixes the amount to pay in `pay_currency`. Send the customer to your own page with `pay_address` and `pay_amount`, or use an invoice for the hosted checkout. A retry with the same `idempotency_key` returns the first payment. In this response `payment_id` and `purchase_id` are strings. Auth: `apiKey` | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `Idempotency-Key` | header | `string` | no | The same as `idempotency_key` in the body. The body field has priority. | | Field | Type | Required | Description | |---|---|---|---| | `price_amount` | `number \| string` | yes | A positive amount, as a JSON number or a decimal string. | | `price_currency` | `string` | yes | A fiat code or a ticker. | | `pay_currency` | `string` | yes | The ticker the customer pays with. | | `pay_amount` | `number \| string` | no | A fixed amount in `pay_currency`. Without it, Turmite converts `price_amount` at the current rate. | | `order_id` | `string` | no | Your order reference. Webhooks include it. | | `order_description` | `string` | no | | | `ipn_callback_url` | `string (uri)` | no | Turmite sends payment webhooks to this URL. On mainnet it must be a public `https` URL. | | `success_url` | `string (uri)` | no | Where the checkout sends the customer after payment. | | `cancel_url` | `string (uri)` | no | | | `purchase_id` | `string` | no | Your id for the purchase. Without it, Turmite makes numeric text. | | `idempotency_key` | `string` | no | A request with a key that the project used before returns the first payment. This is also true for two requests at the same time. Turmite does not compare the other fields. | ```json { "price_amount": 100, "price_currency": "usd", "pay_currency": "btc", "order_id": "1042", "order_description": "Order #1042", "ipn_callback_url": "https://shop.example.com/turmite/ipn", "idempotency_key": "order-1042" } ``` Response `201`: The new payment. ```json { "payment_id": "5077125051", "invoice_id": null, "payment_status": "waiting", "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "payin_extra_id": null, "price_amount": 100, "price_currency": "usd", "pay_amount": 0.00123, "actually_paid": 0, "pay_currency": "btc", "order_id": "1042", "order_description": "Order #1042", "purchase_id": "6084815412", "outcome_amount": null, "outcome_currency": "btc", "network": "btc", "network_precision": 8, "smart_contract": null, "payin_hash": null, "payout_hash": null, "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:00:00.000Z", "expiration_estimate_date": "2026-10-23T12:15:00.000Z", "parent_payment_id": null, "type": "crypto2crypto", "time_limit": null, "burning_percent": null, "payment_extra_ids": null, "amount_received": 0.00123, "actually_paid_at_fiat": 0, "fee": { "currency": "btc", "depositFee": 0, "withdrawalFee": 0, "serviceFee": 0 } } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` Response `503`: A service that the request needs is not available, for example the exchange rates. Try again later. ```json { "status": false, "statusCode": 503, "code": "UNAVAILABLE", "message": "Exchange rates are not available. Try again in a minute." } ``` ### GET /v1/payment/{payment_id} Get a payment Ship the order only when `payment_status` is `finished`. Use webhooks instead of frequent polls. Auth: `apiKey` | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `payment_id` | path | `integer (int64)` | yes | | Response `200`: The payment. ```json { "payment_id": 5077125051, "invoice_id": 4522625843, "payment_status": "finished", "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "payin_extra_id": null, "price_amount": 100, "price_currency": "usd", "pay_amount": 0.00123, "actually_paid": 0.00123, "pay_currency": "btc", "order_id": "1042", "order_description": "Order #1042", "purchase_id": 6084815412, "outcome_amount": 0.00123, "outcome_currency": "btc", "network": "btc", "network_precision": 8, "smart_contract": null, "payin_hash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", "payout_hash": null, "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:14:03.512Z", "expiration_estimate_date": "2026-10-23T12:15:00.000Z", "parent_payment_id": null, "type": "crypto2crypto", "time_limit": null, "burning_percent": null, "payment_extra_ids": null, "amount_received": 0.00123, "actually_paid_at_fiat": 100, "fee": { "currency": "btc", "depositFee": 0, "withdrawalFee": 0, "serviceFee": 0 } } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `404`: Not found, or it belongs to another project. ```json { "status": false, "statusCode": 404, "code": "NOT_FOUND", "message": "not found" } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ## Invoices Hosted invoices. The customer selects the coin on the invoice page. ### POST /v1/invoice Create an invoice Returns `invoice_url`, a hosted checkout page. Send the customer there. The customer selects a coin, and Turmite creates a payment for the invoice. The payment copies `order_id`, `order_description`, `ipn_callback_url`, `success_url` and `cancel_url` from the invoice. Webhooks for that payment go to the invoice's `ipn_callback_url`. Auth: `apiKey` | Field | Type | Required | Description | |---|---|---|---| | `price_amount` | `number \| string` | yes | A positive amount, as a JSON number or a decimal string. | | `price_currency` | `string` | yes | | | `pay_currency` | `string` | no | Set it to skip the coin selection on the invoice page. | | `order_id` | `string` | no | | | `order_description` | `string` | no | | | `ipn_callback_url` | `string (uri)` | no | Turmite sends the webhooks of the invoice's payments to this URL. | | `success_url` | `string (uri)` | no | | | `cancel_url` | `string (uri)` | no | | ```json { "price_amount": 49.9, "price_currency": "eur", "order_id": "1042", "order_description": "Order #1042", "ipn_callback_url": "https://shop.example.com/turmite/ipn", "success_url": "https://shop.example.com/checkout/order-received/1042", "cancel_url": "https://shop.example.com/checkout" } ``` Response `201`: The new invoice. ```json { "id": "4522625843", "token_id": "k3J9xQ2vT8mZ5wR1nB7cY4pL", "order_id": "1042", "order_description": "Order #1042", "price_amount": "49.9", "price_currency": "eur", "pay_currency": null, "ipn_callback_url": "https://shop.example.com/turmite/ipn", "invoice_url": "https://pay.example.com/i/k3J9xQ2vT8mZ5wR1nB7cY4pL", "success_url": "https://shop.example.com/checkout/order-received/1042", "cancel_url": "https://shop.example.com/checkout", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:00:00.000Z", "expiration_estimate_date": "2026-10-23T13:00:00.000Z" } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### POST /v1/invoice-payment Create a payment for an invoice Use this to build your own coin selection for an invoice. The payment gets the invoice's price, order and URLs. If the invoice has an open payment in `pay_currency`, Turmite returns that payment. In this response `payment_id` and `purchase_id` are strings. Auth: `apiKey` | Field | Type | Required | Description | |---|---|---|---| | `iid` | `integer (int64) \| string` | yes | The invoice `id`. | | `pay_currency` | `string` | yes | | | `purchase_id` | `string` | no | | ```json { "iid": "4522625843", "pay_currency": "usdttrc20" } ``` Response `201`: The new payment. ```json { "payment_id": "5077125051", "invoice_id": null, "payment_status": "waiting", "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "payin_extra_id": null, "price_amount": 100, "price_currency": "usd", "pay_amount": 0.00123, "actually_paid": 0, "pay_currency": "btc", "order_id": "1042", "order_description": "Order #1042", "purchase_id": "6084815412", "outcome_amount": null, "outcome_currency": "btc", "network": "btc", "network_precision": 8, "smart_contract": null, "payin_hash": null, "payout_hash": null, "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:00:00.000Z", "expiration_estimate_date": "2026-10-23T12:15:00.000Z", "parent_payment_id": null, "type": "crypto2crypto", "time_limit": null, "burning_percent": null, "payment_extra_ids": null, "amount_received": 0.00123, "actually_paid_at_fiat": 0, "fee": { "currency": "btc", "depositFee": 0, "withdrawalFee": 0, "serviceFee": 0 } } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `404`: Not found, or it belongs to another project. ```json { "status": false, "statusCode": 404, "code": "NOT_FOUND", "message": "not found" } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` Response `503`: A service that the request needs is not available, for example the exchange rates. Try again later. ```json { "status": false, "statusCode": 503, "code": "UNAVAILABLE", "message": "Exchange rates are not available. Try again in a minute." } ``` ## Payouts Send funds from the project's hot wallets. ### GET /v1/payout List payouts Auth: `apiKey`, `bearerAuth` | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `batch_id` | query | `string` | no | Only the payouts of this batch. | | `status` | query | `string` | no | Only the payouts with this status. An unknown status returns `400`. One of: `pending-approval`, `pending-otp-verification`, `pending`, `signing`, `initiated`, `sent`, `failed`, `rejected`, `cancelled`, `needs-review`. | | `date_from` | query | `string` | no | Earliest `created_at`. A date or a date-time. | | `date_to` | query | `string` | no | Latest `created_at`. A date or a date-time. | | `order` | query | `string` | no | One of: `asc`, `desc`. | | `limit` | query | `integer` | no | Payouts per page. Turmite changes a value below 1 to 1 and a value above 500 to 500. | | `page` | query | `integer` | no | Page number. The first page is 0. | Response `200`: One page of payouts. ```json { "payouts": [ { "id": "string", "batch_withdrawal_id": "string", "address": "string", "extra_id": "string", "currency": "string", "network": "string", "amount": "250", "fee": "string", "fee_currency": "string", "status": "pending-approval", "hash": "string", "error": "string", "ipn_callback_url": "string", "payout_description": "string", "unique_external_id": "string", "created_at": "2026-10-23T12:00:00Z", "requested_at": "2026-10-23T12:00:00Z", "updated_at": "2026-10-23T12:00:00Z" } ], "limit": 0, "page": 0, "pagesCount": 0, "total": 0 } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `403`: The key or the user cannot do this action. ```json { "status": false, "statusCode": 403, "code": "FORBIDDEN", "message": "Only a project admin can create or verify payouts." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### POST /v1/payout Create a payout batch Creates one payout per withdrawal. The bearer token must belong to a project admin of the API key's project. Each address must be in the project's address book. New payouts are `pending-otp-verification` until you verify the batch with `POST /v1/payout/{batch_id}/verify`. A withdrawal with a `unique_external_id` that already exists returns the existing payout. The same id with other fields returns `409`. At most 100 withdrawals per batch. Bitcoin and Monero have no payouts. Auth: `apiKey`, `bearerAuth` | Field | Type | Required | Description | |---|---|---|---| | `withdrawals` | `object[]` | yes | | | `withdrawals[].address` | `string` | yes | The destination. It must be in the project's address book. | | `withdrawals[].currency` | `string` | yes | | | `withdrawals[].amount` | `number \| string` | yes | A positive amount, as a JSON number or a decimal string. | | `withdrawals[].extra_id` | `string` | no | The memo or tag of the destination. | | `withdrawals[].ipn_callback_url` | `string (uri)` | no | Turmite sends payout webhooks to this URL. Without it, the batch's `ipn_callback_url` is used. | | `withdrawals[].payout_description` | `string` | no | | | `withdrawals[].unique_external_id` | `string` | no | Your id for this payout. A second request with the same id and fields returns the existing payout. The same id with other fields returns `409`. | | `ipn_callback_url` | `string (uri)` | no | Payout webhooks for each withdrawal without its own `ipn_callback_url`. | ```json { "withdrawals": [ { "address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "currency": "usdttrc20", "amount": "250.00", "ipn_callback_url": "https://shop.example.com/turmite/payout-ipn", "unique_external_id": "refund-1042" } ] } ``` Response `201`: The new batch. ```json { "id": "5000000713", "withdrawals": [ { "id": "5000248325", "batch_withdrawal_id": "5000000713", "address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "extra_id": null, "currency": "usdttrc20", "network": "tron", "amount": "250", "fee": null, "fee_currency": "trx", "status": "pending-otp-verification", "hash": null, "error": null, "ipn_callback_url": "https://shop.example.com/turmite/payout-ipn", "payout_description": null, "unique_external_id": "refund-1042", "created_at": "2026-10-23T13:00:00.000Z", "requested_at": null, "updated_at": "2026-10-23T13:00:00.000Z" } ] } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `403`: The key or the user cannot do this action. ```json { "status": false, "statusCode": 403, "code": "FORBIDDEN", "message": "Only a project admin can create or verify payouts." } ``` Response `409`: The request conflicts with an existing object. ```json { "status": false, "statusCode": 409, "code": "CONFLICT", "message": "`unique_external_id` is used with other fields." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### POST /v1/payout/{batch_id}/verify Verify a payout batch Send the current TOTP code of the user who got the bearer token. That user must be a project admin. Each payout of the batch that waits for verification then goes through the project limits: within them it is `pending`; above the auto-approve amount or the hourly or daily limit it is `pending-approval` until an admin approves it in the dashboard. A wrong code returns `400`. After 5 wrong codes the user is locked for 15 minutes and gets `429`. A user without two-factor authentication gets `403`. Auth: `apiKey`, `bearerAuth` | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `batch_id` | path | `string` | yes | The `id` of the payout batch. | | Field | Type | Required | Description | |---|---|---|---| | `verification_code` | `string` | yes | The current TOTP code of the dashboard user. | ```json { "verification_code": "123456" } ``` Response `200`: The batch after verification. ```json { "id": "string", "withdrawals": [ { "id": "string", "batch_withdrawal_id": "string", "address": "string", "extra_id": "string", "currency": "string", "network": "string", "amount": "250", "fee": "string", "fee_currency": "string", "status": "pending-approval", "hash": "string", "error": "string", "ipn_callback_url": "string", "payout_description": "string", "unique_external_id": "string", "created_at": "2026-10-23T12:00:00Z", "requested_at": "2026-10-23T12:00:00Z", "updated_at": "2026-10-23T12:00:00Z" } ] } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `403`: The key or the user cannot do this action. ```json { "status": false, "statusCode": 403, "code": "FORBIDDEN", "message": "Only a project admin can create or verify payouts." } ``` Response `404`: Not found, or it belongs to another project. ```json { "status": false, "statusCode": 404, "code": "NOT_FOUND", "message": "not found" } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### GET /v1/payout/{batch_id} Get a payout batch Auth: `apiKey` | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `batch_id` | path | `string` | yes | The `id` of the payout batch. | Response `200`: The batch. ```json { "id": "5000000713", "withdrawals": [ { "id": "5000248325", "batch_withdrawal_id": "5000000713", "address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "extra_id": null, "currency": "usdttrc20", "network": "tron", "amount": "250", "fee": null, "fee_currency": "trx", "status": "pending-otp-verification", "hash": null, "error": null, "ipn_callback_url": "https://shop.example.com/turmite/payout-ipn", "payout_description": null, "unique_external_id": "refund-1042", "created_at": "2026-10-23T13:00:00.000Z", "requested_at": null, "updated_at": "2026-10-23T13:00:00.000Z" } ] } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `404`: Not found, or it belongs to another project. ```json { "status": false, "statusCode": 404, "code": "NOT_FOUND", "message": "not found" } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### POST /v1/payout/validate-address Validate a payout address Auth: `apiKey` | Field | Type | Required | Description | |---|---|---|---| | `address` | `string` | yes | | | `currency` | `string` | yes | | ```json { "address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "currency": "usdttrc20" } ``` Response `200`: The address is valid. The body is the JSON string `"OK"`. An invalid address or an unknown currency returns `400`. ```json "OK" ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### GET /v1/payout/fee Estimate the network fee of a payout The fee is an estimate from the chain. Turmite measures the real fee when it sends the payout. The hot wallet pays the fee in the native coin of the network (`network_fee` in `fee_currency`); the recipient gets the full amount. `fee` is that cost in `currency`. A Solana token payout to a wallet without a token account also pays the rent of that account. Auth: `apiKey` | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `currency` | query | `string` | yes | | | `amount` | query | `string` | no | | Response `200`: The estimated fee in `currency`. ```json { "currency": "usdttrc20", "amount": "250", "fee": 1.1, "network_fee": 4.4, "fee_currency": "trx" } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` Response `503`: A service that the request needs is not available, for example the exchange rates. Try again later. ```json { "status": false, "statusCode": 503, "code": "UNAVAILABLE", "message": "Exchange rates are not available. Try again in a minute." } ``` ## Reference Currencies, minimum amounts, estimates and balances. ### GET /v1/status Check that the API is up Response `200`: The API is up. ```json { "message": "OK" } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### GET /v1/currencies List enabled tickers With a key, the tickers enabled for the key's project. Without a key, the tickers that at least one project takes. Auth: `apiKey` Response `200`: Enabled tickers. ```json { "currencies": [ "btc", "eth", "usdttrc20", "sol" ] } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### GET /v1/full-currencies List currencies with details One entry for each ticker that the server has turned on. `enable` tells if the project takes it. Auth: `apiKey` Response `200`: One entry per ticker. ```json { "currencies": [ { "id": 7, "code": "USDTTRC20", "name": "Tether USD", "enable": true, "wallet_regex": null, "priority": 7, "extra_id_exists": false, "extra_id_regex": null, "logo_url": null, "track": false, "cg_id": "tether", "is_maxlimit": false, "network": "tron", "smart_contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "network_precision": "6", "ticker": "usdttrc20" } ] } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### GET /v1/merchant/coins List the tickers enabled for the project Auth: `apiKey` Response `200`: Tickers enabled for the key's project. ```json { "selectedCurrencies": [ "btc", "usdttrc20" ] } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### GET /v1/min-amount Get the minimum payment amount No API key is necessary. Turmite ignores a key that you send. | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `currency_from` | query | `string` | yes | The ticker the customer pays with. | | `currency_to` | query | `string` | no | Turmite does not convert. It copies this value to the response. The default is `currency_from`. | | `fiat_equivalent` | query | `string` | no | A fiat code. When set and a rate is available, the response has `fiat_equivalent`. | Response `200`: The minimum payment amount in `currency_from`. ```json { "currency_from": "btc", "currency_to": "btc", "min_amount": 0.00001, "fiat_equivalent": 1.07 } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ### GET /v1/estimate Estimate a price in a coin No API key is necessary. Turmite ignores a key that you send. | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `amount` | query | `string` | yes | The amount in `currency_from`. | | `currency_from` | query | `string` | yes | The price currency, such as `usd`. | | `currency_to` | query | `string` | yes | The ticker the customer pays with. | Response `200`: The estimate at the current rate. ```json { "currency_from": "usd", "amount_from": 100, "currency_to": "btc", "estimated_amount": "0.00107" } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` Response `503`: A service that the request needs is not available, for example the exchange rates. Try again later. ```json { "status": false, "statusCode": 503, "code": "UNAVAILABLE", "message": "Exchange rates are not available. Try again in a minute." } ``` ### GET /v1/balance Get the project balance per ticker Auth: `apiKey` Response `200`: One entry per ticker. ```json { "btc": { "amount": 0.0153, "pendingAmount": 0.0012 }, "usdttrc20": { "amount": 1250.5, "pendingAmount": 0 } } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ## Auth Bearer tokens for the payout endpoints. ### POST /v1/auth Get a bearer token for the payout endpoints Use the email and password of a dashboard user. The token is valid for 5 minutes. | Field | Type | Required | Description | |---|---|---|---| | `email` | `string (email)` | yes | | | `password` | `string (password)` | yes | | ```json { "email": "ops@shop.example.com", "password": "correct-horse-battery-staple" } ``` Response `200`: The token. ```json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl" } ``` Response `400`: The request is not valid. ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` Response `401`: The API key or the bearer token is missing or not valid. ```json { "status": false, "statusCode": 401, "code": "AUTH_REQUIRED", "message": "Invalid API key." } ``` Response `429`: Too many requests. ```json { "status": false, "statusCode": 429, "code": "TOO_MANY_REQUESTS", "message": "too many requests, try again later" } ``` Response `500`: The server failed. The message has no details. ```json { "status": false, "statusCode": 500, "code": "INTERNAL_ERROR", "message": "Internal error." } ``` ## Webhooks ### POST payment A payment changed Turmite sends the payment object to the `ipn_callback_url` of the payment, or of its invoice. It sends one on each status change, one for each new confirmation count while the payment is `confirming`, and one on each extra credit after `finished`. Ship the order only on `finished`. In this body `payment_id` is a number and `purchase_id` is a string. Check the signature before you use the body. The signature header (default `x-turmite-sig`) holds the hex HMAC-SHA512 of the key-sorted JSON body. The key is the project IPN secret. To get the key-sorted JSON from the raw body: 1. Sort the members of each object by key. Compare keys as UTF-8 bytes. Keep the order of arrays. 2. Write compact JSON with no whitespace. 3. Copy each number exactly as it is in the raw body, for example `0.5` or `1e-6`. 4. In strings, escape only `"`, `\` and control characters. Do not escape `/` or non-ASCII characters. `x-turmite-signature` holds `v1=` and the hex HMAC-SHA256 of `.`, with the same secret. Refuse a timestamp more than 5 minutes from your clock. Use `x-turmite-delivery` to ignore a delivery that you already processed. A 2xx response acknowledges the delivery. Progress events get 3 attempts: now, after 10 s and after 60 s. Final events get attempts now and after 10 s, 1 m, 5 m, 30 m, 1 h, 2 h, 4 h, 8 h, 24 h and 48 h. Then the delivery is dead. You can retry it from the dashboard. | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `x-turmite-sig` | header | `string` | yes | Hex HMAC-SHA512 of the key-sorted JSON body. The key is the project IPN secret for an `ipn_callback_url`, or the endpoint secret for a project endpoint. The header name is a project setting. The default is `x-turmite-sig`. | | `x-turmite-timestamp` | header | `string` | yes | Unix time in seconds when Turmite signed this attempt. | | `x-turmite-signature` | header | `string` | yes | `v1=` and the hex HMAC-SHA256 of `.`. The key is the same as for the signature header. | | `x-turmite-event` | header | `string` | yes | The event name, such as `payment.finished`, `payout.sent` or `test`. | | `x-turmite-delivery` | header | `string` | yes | The delivery id. Retries of a delivery use the same id. Use it to ignore duplicates. | | `user-agent` | header | `string` | yes | | | Field | Type | Required | Description | |---|---|---|---| | `invoice_id` | `integer (int64) \| null` | yes | | | `payment_status` | `string` | yes | `waiting`: no deposit yet. `confirming`: a deposit is on the chain with too few confirmations. `confirmed`: the deposit has enough confirmations. `sending`: Turmite moves the funds to the wallet of the project. `partially_paid`: the customer sent less than `pay_amount`. `finished`: paid in full. Ship the order now. `failed`, `refunded` and `expired` are final. One of: `waiting`, `confirming`, `confirmed`, `sending`, `partially_paid`, `finished`, `failed`, `refunded`, `expired`. | | `pay_address` | `string` | yes | The deposit address. | | `payin_extra_id` | `string \| null` | yes | The memo or tag the customer must add, on networks that use one. | | `price_amount` | `number` | yes | The price in `price_currency`. | | `price_currency` | `string` | yes | Lowercase. | | `pay_amount` | `number` | yes | The amount to pay in `pay_currency`. | | `actually_paid` | `number` | yes | The amount received so far. | | `pay_currency` | `string` | yes | | | `order_id` | `string \| null` | yes | | | `order_description` | `string \| null` | yes | | | `outcome_amount` | `number \| null` | yes | The amount credited to the project after network fees. | | `outcome_currency` | `string` | yes | | | `network` | `string` | yes | The network code. One of `btc`, `xmr`, `eth`, `base`, `polygon`, `bsc`, `tron`, `sol`, `ton`. | | `network_precision` | `integer` | yes | The decimals of `pay_currency`. | | `smart_contract` | `string \| null` | yes | The token contract. Null for a native coin. | | `payin_hash` | `string \| null` | yes | The transaction hash of the latest deposit. | | `payout_hash` | `string \| null` | yes | The transaction hash of the latest transfer from the deposit address to the project wallet. | | `payment_url` | `string (uri)` | yes | The hosted payment page, `/pay/`. The token is random. | | `created_at` | `string (date-time)` | yes | | | `updated_at` | `string (date-time)` | yes | | | `expiration_estimate_date` | `string (date-time)` | yes | When the payment expires if no deposit comes. | | `parent_payment_id` | `null` | yes | Always null. | | `type` | `string` | yes | Always `crypto2crypto`. One of: `crypto2crypto`. | | `time_limit` | `null` | yes | Always null. | | `burning_percent` | `null` | yes | Always null. | | `payment_extra_ids` | `null` | yes | Always null. | | `amount_received` | `number` | yes | The amount the project gets in `outcome_currency`. `outcome_amount` once funds are credited, else `pay_amount`. | | `actually_paid_at_fiat` | `number` | yes | `actually_paid` in `price_currency`, at the rate of the payment. A payment with a fixed `pay_amount` uses `price_amount / pay_amount` as the rate. | | `fee` | `object` | yes | Turmite takes no fee. `withdrawalFee` is the network fee of the sweeps to the project wallet. | | `fee.currency` | `string` | yes | The same as `pay_currency`. | | `fee.depositFee` | `number` | yes | Always 0. | | `fee.withdrawalFee` | `number` | yes | | | `fee.serviceFee` | `number` | yes | Always 0. | | `payment_id` | `integer (int64)` | yes | | | `purchase_id` | `string` | yes | The `purchase_id` of the request, or numeric text that Turmite makes. | ```json { "payment_id": 5077125051, "invoice_id": 4522625843, "payment_status": "finished", "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "payin_extra_id": null, "price_amount": 100, "price_currency": "usd", "pay_amount": 0.00123, "actually_paid": 0.00123, "pay_currency": "btc", "order_id": "1042", "order_description": "Order #1042", "purchase_id": "6084815412", "outcome_amount": 0.00123, "outcome_currency": "btc", "network": "btc", "network_precision": 8, "smart_contract": null, "payin_hash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", "payout_hash": null, "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:14:03.512Z", "expiration_estimate_date": "2026-10-23T12:15:00.000Z", "parent_payment_id": null, "type": "crypto2crypto", "time_limit": null, "burning_percent": null, "payment_extra_ids": null, "amount_received": 0.00123, "actually_paid_at_fiat": 100, "fee": { "currency": "btc", "depositFee": 0, "withdrawalFee": 0, "serviceFee": 0 } } ``` Response `200`: Any 2xx status acknowledges the delivery. Response `400`: Any other status makes Turmite retry on the schedule. ### POST payout A payout changed Turmite sends the payout object to the `ipn_callback_url` of the withdrawal (or of its batch) on each status change, with the event `payout.` in `x-turmite-event`. The key is the project IPN secret. The headers, signatures and retries are the same as for the payment webhook. `sent`, `failed`, `rejected` and `cancelled` are final events. | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `x-turmite-sig` | header | `string` | yes | Hex HMAC-SHA512 of the key-sorted JSON body. The key is the project IPN secret for an `ipn_callback_url`, or the endpoint secret for a project endpoint. The header name is a project setting. The default is `x-turmite-sig`. | | `x-turmite-timestamp` | header | `string` | yes | Unix time in seconds when Turmite signed this attempt. | | `x-turmite-signature` | header | `string` | yes | `v1=` and the hex HMAC-SHA256 of `.`. The key is the same as for the signature header. | | `x-turmite-event` | header | `string` | yes | The event name, such as `payment.finished`, `payout.sent` or `test`. | | `x-turmite-delivery` | header | `string` | yes | The delivery id. Retries of a delivery use the same id. Use it to ignore duplicates. | | `user-agent` | header | `string` | yes | | | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `batch_withdrawal_id` | `string` | yes | | | `address` | `string` | yes | | | `extra_id` | `string \| null` | yes | | | `currency` | `string` | yes | | | `network` | `string` | yes | The network code in lower case, such as `eth` or `tron`. | | `amount` | `string` | yes | A decimal number as text. | | `fee` | `string \| null` | yes | The network fee in `fee_currency`, when known. The hot wallet pays it. | | `fee_currency` | `string` | yes | The native coin of the network, such as `eth` or `trx`. | | `status` | `string` | yes | `pending-otp-verification`: waits for a one-time code. An API batch waits for `verify`, or the admin who clicked Approve in the dashboard must enter the code that Turmite emailed. `pending-approval`: above a project limit; waits for an admin in the dashboard. `pending`: cleared to send. `signing`: signed and stored, no node took it yet. `initiated`: a node took the transaction. `sent`: on the chain with the network's confirmations. `sent`, `failed`, `rejected` and `cancelled` are final. `needs-review`: Turmite cannot tell whether the payout went out (its nonce is used by another transaction). An admin checks the chain and records the result. One of: `pending-approval`, `pending-otp-verification`, `pending`, `signing`, `initiated`, `sent`, `failed`, `rejected`, `cancelled`, `needs-review`. | | `hash` | `string \| null` | yes | The transaction hash. | | `error` | `string \| null` | yes | | | `ipn_callback_url` | `string (uri) \| null` | yes | | | `payout_description` | `string \| null` | yes | | | `unique_external_id` | `string \| null` | yes | | | `created_at` | `string (date-time)` | yes | | | `requested_at` | `string (date-time) \| null` | yes | | | `updated_at` | `string (date-time)` | yes | | ```json { "id": "5000248325", "batch_withdrawal_id": "5000000713", "address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "extra_id": null, "currency": "usdttrc20", "network": "tron", "amount": "250", "fee": "4.4", "fee_currency": "trx", "status": "sent", "hash": "9f4c0e5b2a7d1c3e8f6a0b4d2c9e7f1a3b5d8c0e2f4a6b8d0c2e4f6a8b0d2c4e", "error": null, "ipn_callback_url": "https://shop.example.com/turmite/payout-ipn", "payout_description": "Refund for order 1042", "unique_external_id": "refund-1042", "created_at": "2026-10-23T13:00:00.000Z", "requested_at": "2026-10-23T13:01:10.000Z", "updated_at": "2026-10-23T13:02:45.000Z" } ``` Response `200`: Any 2xx status acknowledges the delivery. Response `400`: Any other status makes Turmite retry on the schedule. ### POST event An event for a project endpoint Turmite sends this envelope to each endpoint that you add under Webhooks in the dashboard. `event` is `payment.` with a payment in `data`, `payout.` with a payout in `data`, or `test` with a message in `data`. Each endpoint has its own secret. The dashboard shows it. Turmite signs both signature headers with it. The headers, the signature rules and the retries are the same as for the payment webhook. | Parameter | In | Type | Required | Description | |---|---|---|---|---| | `x-turmite-sig` | header | `string` | yes | Hex HMAC-SHA512 of the key-sorted JSON body. The key is the project IPN secret for an `ipn_callback_url`, or the endpoint secret for a project endpoint. The header name is a project setting. The default is `x-turmite-sig`. | | `x-turmite-timestamp` | header | `string` | yes | Unix time in seconds when Turmite signed this attempt. | | `x-turmite-signature` | header | `string` | yes | `v1=` and the hex HMAC-SHA256 of `.`. The key is the same as for the signature header. | | `x-turmite-event` | header | `string` | yes | The event name, such as `payment.finished`, `payout.sent` or `test`. | | `x-turmite-delivery` | header | `string` | yes | The delivery id. Retries of a delivery use the same id. Use it to ignore duplicates. | | `user-agent` | header | `string` | yes | | | Field | Type | Required | Description | |---|---|---|---| | `event` | `string` | yes | `payment.`, `payout.` or `test`. | | `delivery_id` | `string` | yes | The same value as the `x-turmite-delivery` header. | | `created_at` | `integer (int64)` | yes | Unix time in seconds when Turmite made the event. | | `data` | `any \| object \| object` | yes | | ```json { "event": "payment.finished", "delivery_id": "dK4rT8wQ2mN6xP1vB9cL5zH7", "created_at": 1761221644, "data": { "payment_id": 5077125051, "invoice_id": 4522625843, "payment_status": "finished", "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "payin_extra_id": null, "price_amount": 100, "price_currency": "usd", "pay_amount": 0.00123, "actually_paid": 0.00123, "pay_currency": "btc", "order_id": "1042", "order_description": "Order #1042", "purchase_id": "6084815412", "outcome_amount": 0.00123, "outcome_currency": "btc", "network": "btc", "network_precision": 8, "smart_contract": null, "payin_hash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", "payout_hash": null, "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "created_at": "2026-10-23T12:00:00.000Z", "updated_at": "2026-10-23T12:14:03.512Z", "expiration_estimate_date": "2026-10-23T12:15:00.000Z", "parent_payment_id": null, "type": "crypto2crypto", "time_limit": null, "burning_percent": null, "payment_extra_ids": null, "amount_received": 0.00123, "actually_paid_at_fiat": 100, "fee": { "currency": "btc", "depositFee": 0, "withdrawalFee": 0, "serviceFee": 0 } } } ``` Response `200`: Any 2xx status acknowledges the delivery. Response `400`: Any other status makes Turmite retry on the schedule. # Webhooks > Receive signed HTTP requests when a payment or a payout changes status, and verify them. Source: https://turmite.com/docs/webhooks/ Turmite sends an HTTP `POST` to your server when a payment or a payout changes status. Each request is signed. Verify the signature before you use the body. ## Kinds of webhook | Kind | Where you set it | Body | |---|---|---| | Per-payment IPN | `ipn_callback_url` in `POST /v1/payment` or `POST /v1/invoice` | The flat [payment object](/docs/merchant-api/#the-payment-object) | | Per-payout IPN | `ipn_callback_url` of a withdrawal or of the batch in `POST /v1/payout` | The flat [payout object](/docs/payouts/#the-payout-object) | | Project endpoint | Dashboard → **Webhooks** → **Endpoint URL** → **Add endpoint** | An envelope with `event`, `delivery_id`, `created_at` and `data` | The per-payment IPN works with code written for the common hosted-gateway API. A payment made from an invoice sends its IPN to the invoice's `ipn_callback_url`. Project endpoints receive payment events, payout events and test events. A user with the `developer` role can add and delete endpoints. Turmite sends a payment event at each status change, also when a payment returns to an earlier status. After `finished`, a later deposit sends the `finished` event again with the new `actually_paid`, and its credit sends it with the new `outcome_amount`, for example for a late top-up. The per-payment IPN also gets an event at each new confirmation count while the status is `confirming`. Example body for a per-payment IPN: ```json { "actually_paid": 0.00041235, "created_at": "2026-10-23T12:00:00.000Z", "expiration_estimate_date": "2026-10-23T13:00:00.000Z", "invoice_id": null, "network": "btc", "network_precision": 8, "order_description": "Order 1001", "order_id": "ORDER-1001", "outcome_amount": 0.00041235, "outcome_currency": "btc", "pay_address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "pay_amount": 0.00041235, "pay_currency": "btc", "payin_extra_id": null, "payin_hash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", "payment_id": 5524759814, "payment_status": "finished", "payment_url": "https://pay.example.com/pay/Q7mX2pK9vT4nB8cR1sW6yZ3h", "payout_hash": null, "price_amount": 25, "price_currency": "usd", "purchase_id": "4709931082", "smart_contract": null, "updated_at": "2026-10-23T12:41:07.000Z" } ``` Example body for a project endpoint: ```json { "event": "payment.finished", "delivery_id": "dK4rT8wQ2mN6xP1vB9cL5zH7", "created_at": 1792759268, "data": { "payment_id": 5524759814, "payment_status": "finished", "order_id": "ORDER-1001" } } ``` `data` holds the full payment object or payout object. The example shows three fields only. A `test` event has `{"message": "Test event from Turmite."}` in `data`. `delivery_id` is a random token of 24 letters and digits, the same value as the `x-turmite-delivery` header. `created_at` is Unix time in seconds. In both bodies, `payment_id` is a number and `purchase_id` is a string. ## Events | Event | Sent when | |---|---| | `payment.` | A payment gets a new status, for example `payment.confirming` or `payment.finished` | | `payout.` | A payout gets a new status, for example `payout.sent` or `payout.needs-review` | | `test` | You click **Send test** in the dashboard | The payment statuses are in [Payment statuses](/docs/statuses/). The payout statuses are in [Payouts](/docs/payouts/#statuses). ## Headers Every delivery has these headers. | Header | Value | |---|---| | `content-type` | `application/json` | | Signature header, default `x-turmite-sig` | Hex HMAC-SHA512 of the key-sorted JSON body. Key: the secret of the webhook. See [The secrets](#the-secrets) | | `x-turmite-timestamp` | Unix time in seconds when Turmite signed this attempt | | `x-turmite-signature` | `v1=` and the hex HMAC-SHA256 of `.`. Key: the same secret | | `x-turmite-event` | Event name, for example `payment.finished` | | `x-turmite-delivery` | Delivery ID. Retries of one delivery use the same ID | | `user-agent` | `turmite-webhook/1` | A project admin can change the name of the signature header in **Settings** → **General** → **IPN signature header**. Set it to the header name that your existing integration reads. The value does not change. The name can have up to 64 letters, digits and hyphens. Turmite stores it in lowercase. It cannot start with `x-turmite-`, except `x-turmite-sig`. ## The secrets Both signatures of one delivery use the same secret. Which secret depends on the kind of webhook. | Webhook | Secret | Where to get it | |---|---|---| | `ipn_callback_url` of a payment, an invoice or a withdrawal | The project IPN secret | **Settings** → **IPN secret**. Enter a fresh two-factor code and click **Show secret** | | Project endpoint | The signing secret of that endpoint | Shown one time under **Signing secret**, when you add the endpoint | To change the IPN secret, a project admin clicks **Rotate** under **IPN secret** and enters a fresh two-factor code. To change an endpoint secret, add a new endpoint, switch your receiver to it, then delete the old endpoint. Each secret is 48 hex characters. Turmite stores each secret sealed with `TURMITE_SECRET`. Keep the secrets on your server, with the API key. ## Which signature to use - For new code, verify `x-turmite-signature`. It covers a timestamp, so you can refuse replayed requests. - For an existing integration, keep the signature check that it has. Set the signature header name in **Settings** if your code reads another name. You can check both. ## Verify the timestamp signature 1. Read the raw body bytes before you parse JSON. 2. Read `x-turmite-timestamp`. Refuse the request if it is more than 5 minutes from your clock. 3. Calculate HMAC-SHA256 with the secret over the timestamp, a full stop and the raw body. 4. Encode the result as lowercase hex and add `v1=` before it. 5. Compare the result with `x-turmite-signature` in constant time. The examples use the IPN secret in `TM_IPN_SECRET`. For a project endpoint, use the secret of the endpoint. ### Node.js ```js import crypto from 'node:crypto'; import express from 'express'; const secret = process.env.TM_IPN_SECRET; function verifyV1(rawBody, headers) { const ts = String(headers['x-turmite-timestamp'] ?? ''); const sig = String(headers['x-turmite-signature'] ?? ''); if (!Buffer.isBuffer(rawBody)) return false; if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const mac = crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex'); const want = Buffer.from(`v1=${mac}`); const got = Buffer.from(sig); return want.length === got.length && crypto.timingSafeEqual(want, got); } const app = express(); app.post('/turmite/webhook', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyV1(req.body, req.headers)) return res.sendStatus(401); const event = JSON.parse(req.body.toString('utf8')); // Update the order here. Make this step idempotent. res.sendStatus(200); }); app.listen(3000); ``` ### PHP ```php 300 || !hash_equals($want, $sig)) { http_response_code(401); exit; } $event = json_decode($raw, true); // Update the order here. Make this step idempotent. http_response_code(200); ``` ### Python ```python import hashlib import hmac import os import time from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["TM_IPN_SECRET"].encode() def verify_v1(raw: bytes, headers) -> bool: ts = headers.get("x-turmite-timestamp", "") sig = headers.get("x-turmite-signature", "") if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return False mac = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest() return hmac.compare_digest("v1=" + mac, sig) @app.post("/turmite/webhook") def webhook(): raw = request.get_data() if not verify_v1(raw, request.headers): abort(401) event = request.get_json() # Update the order here. Make this step idempotent. return "", 200 ``` ### Go ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "time" ) var secret = []byte(os.Getenv("TM_IPN_SECRET")) func verifyV1(h http.Header, body []byte) bool { ts := h.Get("X-Turmite-Timestamp") n, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if d := time.Now().Unix() - n; d > 300 || d < -300 { return false } mac := hmac.New(sha256.New, secret) mac.Write([]byte(ts + ".")) mac.Write(body) want := "v1=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(want), []byte(h.Get("X-Turmite-Signature"))) } func webhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil || !verifyV1(r.Header, body) { w.WriteHeader(http.StatusUnauthorized) return } // Parse body and update the order here. Make this step idempotent. w.WriteHeader(http.StatusOK) } func main() { http.HandleFunc("POST /turmite/webhook", webhook) http.ListenAndServe(":3000", nil) } ``` ### Ruby ```ruby require "json" require "openssl" require "sinatra" SECRET = ENV.fetch("TM_IPN_SECRET") def verify_v1(raw, ts, sig) return false unless ts.to_s.match?(/\A\d+\z/) return false if (Time.now.to_i - ts.to_i).abs > 300 want = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{raw}") OpenSSL.secure_compare(want, sig.to_s) end post "/turmite/webhook" do raw = request.body.read ts = request.env["HTTP_X_TURMITE_TIMESTAMP"] sig = request.env["HTTP_X_TURMITE_SIGNATURE"] halt 401 unless verify_v1(raw, ts, sig) event = JSON.parse(raw) # Update the order here. Make this step idempotent. status 200 end ``` ## Verify the compatible signature This is the signature of the common hosted-gateway API. Existing integrations already check it. 1. Make the key-sorted JSON from the raw body: - Sort the members of each object by key. Compare keys as UTF-8 bytes. Keep the order of arrays. - Write compact JSON with no whitespace. - Copy each number exactly as it is in the raw body. - In strings, escape only `"`, `\` and control characters. Do not escape `/` or non-ASCII characters. 2. Calculate HMAC-SHA512 with the secret over that text. 3. Encode the result as lowercase hex. 4. Compare the result with the signature header in constant time. The body is the key-sorted JSON. Turmite writes numbers as JavaScript does: a whole amount as an integer (`25`), other amounts in the shortest decimal form (`0.00041235`, `0.000001`), and an exponent only below 0.0000001 or from 1e21 (`1e-7`, `1.5e-7`, `1e+21`). In JavaScript, `JSON.stringify` of the parsed body with sorted keys gives the body again. A JSON parser that changes the text of a number breaks the check. In Node.js, `verifyIpnSignature` from the [TypeScript SDK](/docs/sdk/) keeps the text of each number. The Go example keeps it with `UseNumber`. The PHP, Python and Ruby examples keep integers and decimals from 0.0001 up. They can change a smaller number. For such amounts, use the timestamp signature. The examples read the default header `x-turmite-sig`. If you changed the header name in **Settings**, read that name. ### Node.js ```js import { verifyIpnSignature } from '@turmite/sdk'; // In an Express route with express.raw({ type: 'application/json' }): const ok = await verifyIpnSignature(req.body, req.headers['x-turmite-sig'], process.env.TM_IPN_SECRET); if (!ok) return res.sendStatus(401); const payment = JSON.parse(req.body.toString('utf8')); ``` ### PHP ```php $item) { $value[$key] = tm_sort_keys($item); } } return $value; } $secret = getenv('TM_IPN_SECRET'); $raw = file_get_contents('php://input'); $sig = strtolower($_SERVER['HTTP_X_TURMITE_SIG'] ?? ''); $data = json_decode($raw, true); $canonical = json_encode(tm_sort_keys($data), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $want = hash_hmac('sha512', $canonical, $secret); if (!is_array($data) || !hash_equals($want, $sig)) { http_response_code(401); exit; } // $data is the payment object. Update the order here. http_response_code(200); ``` `array_is_list` needs PHP 8.1 or later. ### Python ```python import hashlib import hmac import json def verify_compat(raw: bytes, signature: str, ipn_secret: bytes) -> bool: canonical = json.dumps( json.loads(raw), sort_keys=True, separators=(",", ":"), ensure_ascii=False ) want = hmac.new(ipn_secret, canonical.encode(), hashlib.sha512).hexdigest() return hmac.compare_digest(want, (signature or "").lower()) # Flask: verify_compat(request.get_data(), request.headers.get("x-turmite-sig"), SECRET) ``` ### Go ```go package webhook import ( "bytes" "crypto/hmac" "crypto/sha512" "encoding/hex" "encoding/json" "strings" ) // Go encodes map keys in sorted order. UseNumber keeps each number as it was sent. func VerifyCompat(body []byte, signature string, ipnSecret []byte) bool { dec := json.NewDecoder(bytes.NewReader(body)) dec.UseNumber() var v any if err := dec.Decode(&v); err != nil { return false } var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return false } canonical := bytes.TrimSuffix(buf.Bytes(), []byte("\n")) mac := hmac.New(sha512.New, ipnSecret) mac.Write(canonical) want := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(want), []byte(strings.ToLower(signature))) } // In a handler: VerifyCompat(body, r.Header.Get("X-Turmite-Sig"), secret) ``` ### Ruby ```ruby require "json" require "openssl" def sort_keys(value) case value when Hash then value.keys.sort.to_h { |k| [k, sort_keys(value[k])] } when Array then value.map { |v| sort_keys(v) } else value end end def verify_compat(raw, signature, ipn_secret) canonical = JSON.generate(sort_keys(JSON.parse(raw))) want = OpenSSL::HMAC.hexdigest("SHA512", ipn_secret, canonical) OpenSSL.secure_compare(want, signature.to_s.downcase) end # Sinatra: verify_compat(request.body.read, request.env["HTTP_X_TURMITE_SIG"], ENV.fetch("TM_IPN_SECRET")) ``` ## Delivery rules - Turmite sends `POST` with a JSON body. - A `2xx` response marks the attempt as delivered. Any other response or a timeout is a failure. - Turmite does not follow redirects. A `3xx` response is a failure. - On mainnet, the URL must be an `https` URL. Turmite refuses private and reserved IP addresses when it connects, so a DNS record that points to a private address does not work. - On testnet, `http` URLs and private addresses such as `localhost` are allowed for testing. - Each attempt has a timeout of 30 seconds, with 10 seconds to connect. Respond quickly. Do slow work, such as emails, after you send the response. ## Retries | Kind | Attempts | |---|---| | Progress events | 3: now, then after 10 s and 60 s | | Final events | 11: now, then after 10 s, 1 m, 5 m, 30 m, 1 h, 2 h, 4 h, 8 h, 24 h and 48 h | After the last attempt, the delivery status becomes `dead`. A later status change sends a new event, so Turmite retries progress events fewer times. | Final events | Progress events | |---|---| | `payment.finished`, `payment.refunded`, `payment.expired`, `payment.failed` | `payment.waiting`, `payment.confirming`, `payment.confirmed`, `payment.partially_paid` | | `payout.sent`, `payout.failed`, `payout.rejected`, `payout.cancelled` | The other `payout.*` events, `payout.needs-review` included | | | `test` | `payment.partially_paid` is a progress event: the customer can still send the rest. `payment.failed` is in the list for compatibility. Turmite does not set the `failed` status on payments. To send a delivery again, open **Webhooks**, find it under **Deliveries** and click **Retry**. Retry works for every delivery that is not in progress, also a delivered one. The list shows the last 50 deliveries. ## Handle events safely - Make your handler idempotent. The same delivery can arrive more than one time. Store `x-turmite-delivery` and ignore repeats. Each target gets its own delivery ID for the same event. - Do not depend on the order of deliveries. Use `payment_status` and `updated_at` from the signed body. Ignore a status that is older than the one you stored. - Take the event from the signed body. `x-turmite-event` and `x-turmite-delivery` are not signed. - Check that `order_id` and `price_amount` match your order. - Ship the order on `finished` only. - For a large order, read the payment again with `GET /v1/payment/{payment_id}` before you ship. ## Test webhooks 1. Open **Webhooks** in the dashboard. 2. Find the endpoint under **Endpoints**. 3. Click **Send test**. Turmite sends a `test` event, signed with the endpoint secret. 4. Look under **Deliveries** for the result and the number of tries. **Send test** works for project endpoints only. On a testnet install, pay with testnet coins to see real `payment.*` events. On your own computer, run Turmite with `turmite serve --demo` and point the endpoint at your local receiver. See [Quick start](/docs/quick-start/). A signature mismatch has two usual causes: - The framework parsed the body before your check. Read the raw bytes, for example with `express.raw()`. - The secret is wrong or has extra spaces. # Checkout > The invoice page and the payment page that customers see, how they work without JavaScript, and how to brand them. Source: https://turmite.com/docs/checkout/ Turmite serves the pages that customers use to pay. Every page works with JavaScript off. All motion on the pages is CSS. ## Pages | Path | Page | |---|---| | `/i/{invoice_token}` | Invoice. Shows the amount and the coins, grouped by network | | `/pay/{payment_token}` | Payment. Shows the amount, the network, the address, the memo, the QR code and the countdown | | `/pay/{payment_token}/status` | Status. Loaded in a frame on the payment page | | `/x/{payment_token}` | Pay with another coin: the coin list. See [Pay with any coin](/docs/exchange/) | | `/x/{swap_token}` | The swap: the exchange's deposit address and amount | | `/x/{swap_token}/status` | Swap status. Loaded in a frame on the swap page | The tokens are random. They are not the numeric `payment_id`, so a customer cannot guess the link of another payment. ## How the customer pays 1. Your shop creates an invoice with `POST /v1/invoice` and sends the customer to `invoice_url`. 2. The customer selects a coin. Each coin is a form button. An invoice with a `pay_currency` skips this step. 3. Turmite creates the payment and redirects the customer to `/pay/{payment_token}`. When the customer selects the same coin again, Turmite uses the open payment. 4. The customer sends the amount to the address. The QR code is inline SVG. **Open in wallet** opens a payment link in the customer's wallet app. 5. The status frame shows the progress until the payment is final. 6. When the payment is paid, the frame shows a "Return to {store}" button. When the payment ends while the page is open, the frame also shows **Done**. It loads the page again without the address and the QR code. While the invoice is open and the payment is not final, the payment page has the link **Choose another coin**. If you create the payment with `POST /v1/payment`, your shop shows the address and the amount on its own page. See [Merchant API](/docs/merchant-api/#payments-or-invoices). ## Without JavaScript The pages use HTML forms and CSS only. - The coin list is a form. Each coin button sends a `POST`. Turmite answers with a `303` redirect to the payment page. - The status frame reloads itself with `` every 4 seconds. It stops when the payment is `finished`, `expired`, `failed` or `refunded`. - Each reload adds `?seen=` with the state that the frame showed. The animation of a new state plays only once. - Looping animations take their position from the server clock, so a reload does not restart them. - The countdown is a CSS animation. Its duration is the number of seconds until the payment window closes. Then it shows "Expired" and the page dims the address. The customer does not need to reload the page. ## Status on the page | Title on the page | Payment status | The frame shows | |---|---|---| | Waiting for payment | `waiting` | "Send {amount} {coin} to the address below." The countdown | | Payment received | `confirming` | The confirmations, for example `1 of 3`, and an estimate of the time left | | Partly paid | `partially_paid` | The amount received and the rest. "Send {rest} {coin} more to the same address." The countdown | | Payment confirmed | `confirmed` | "Almost done." | | Paid | `finished` | A check mark that draws itself, in green. The "Return to {store}" button | | Expired | `expired` | A cross. "The payment window closed. Do not send funds to this address." | | Payment failed | `failed` | A cross, in red. "This payment cannot be completed. Contact {store}." | | Refunded | `refunded` | "{store} sent this payment back." | The status frame shows a 7 x 7 grid with the Turmite mark. The confirmations fill the grid. The number of confirmations comes from the network settings. See [Networks and nodes](/docs/nodes/#confirmations). Green and red are the only status colours. Green means paid. Red means failed. ## Reduced motion The pages respect `prefers-reduced-motion`. When the customer's system asks for reduced motion, nothing moves. The countdown still counts down. ## Return to the store The "Return to {store}" button opens: | Payment status | Target | |---|---| | `finished` | The `success_url` of the payment or the invoice. Else the project's **Return URL** | | `expired`, `failed` | The `cancel_url` of the payment or the invoice. Else the project's **Return URL** | | `refunded` | The project's **Return URL** | Each payment keeps the project's **Return URL** of the time when the payment was created. Without a target, the page shows no button. When the invoice is still open, an expired payment also shows **Start again**. When an invoice or a payment has a `cancel_url`, the page shows the link "Cancel and return to {store}". The button is for the customer only. Mark the order as paid from a webhook. Do not mark it as paid when the customer opens the return page. See [Webhooks](/docs/webhooks/). ### Allowed return hosts Set the hosts in **Settings → Checkout → Allowed return hosts**, one host per line, at most 50. - When the list is empty, Turmite accepts a `success_url` and a `cancel_url` on any host. - When the list has hosts, the merchant API answers `400` with `INVALID_REQUEST_PARAMS` for a `success_url` or `cancel_url` on another host. The **Return URL** must also use one of the hosts. - The host must match exactly. Add `www.shop.example.com` and `shop.example.com` separately if you use both. All these URLs must use `https://` on a mainnet install. A testnet install also accepts `http://`. ## Branding Each project has its own branding. Set it in **Settings → Checkout**. You need the `project_admin` role. Saving needs no two-factor code. The preview on the right shows the checkout with sample data and your saved settings. | Setting | Effect | |---|---| | **Image file** or **Or an image URL** | The logo at the top of the page. PNG, JPEG or WebP, at most 2 MB. SVG is refused. Turmite scales the logo to fit 512 x 512 pixels and stores it as PNG | | **Theme** | **Auto**, **Light** or **Dark**. Auto follows the customer's system | | **Accent colour** | Used for buttons, progress and the countdown. **Use black and white** keeps the default | | **Message on the checkout** | Short text on the page, for example delivery times. At most 280 characters | | **Support email**, **Support page** | Links at the foot of the page | | **Return URL** | Default target of the "Return to {store}" button | | **Allowed return hosts** | See above | The project name in **Settings → General** is the store name on the checkout. Without a logo, the page shows the first letter of the name. Turmite downloads a logo URL once. The customer's browser loads the logo from Turmite, not from your site. The source image can have at most 8192 pixels on each side. The time limits are in **Settings → General**: | Setting | Default | Range | Effect | |---|---|---|---| | **Payment window (minutes)** | 60 | 5 to 10080 | Time to pay once a coin is chosen. The countdown uses it | | **Invoice window (minutes)** | 1440 | 5 to 43200 | How long an invoice link stays open | ## Checkout domains A project can show its checkout on its own host name, for example `pay.shop-two.com`. On that host, Turmite serves only `/pay/`, `/i/`, `/x/`, `/v1/`, `/assets/`, `/branding/`, `/healthz` and `/favicon.svg`. The pages of other projects answer `404`. See [Projects and checkout domains](/docs/projects/#give-a-project-a-checkout-domain). ## Security The checkout pages send this content security policy: ```text default-src 'none'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none' ``` The status frames send the same policy without `frame-src`, and with `frame-ancestors 'self'`. - The pages run no scripts. - `frame-ancestors 'none'` and `X-Frame-Options: DENY` stop other sites from showing the checkout in a frame. Link or redirect to the checkout. Do not put it in an iframe on your shop. - Forms only send to Turmite. - The status frame comes from the same host as the payment page. - The pages send `Cache-Control: no-store`. - Each IP address can load 300 checkout pages per minute. # Payouts > Send funds from a hot wallet for refunds and other payments, with an address book, limits and two-factor approval. Source: https://turmite.com/docs/payouts/ A payout sends funds from a hot wallet to an address. Use payouts for refunds, supplier payments and withdrawals. You create payouts in the dashboard or with the merchant API. ## Networks | Network | Payouts | |---|---| | Ethereum, Base, Polygon, BNB Smart Chain | Yes: the native coin and ERC-20 tokens | | Solana | Yes: SOL and SPL tokens | | Tron | Yes: TRX and TRC-20 tokens | | TON | Native TON only. Turmite refuses jetton payouts | | Bitcoin | No. Turmite has no Bitcoin key | | Monero | No. Turmite has a view key only | A payout on Bitcoin or Monero gets `400`. ## How hot wallets work A hot wallet is a key that Turmite holds. It pays payouts and the network fees of sweeps. - The `api` process encrypts each generated or imported key to `TURMITE_HOT_RECIPIENT`. It cannot decrypt keys. - Only the `worker` process can decrypt hot keys. It needs `TURMITE_HOT_IDENTITY_FILE` and the passphrase. - The worker opens no port. - Payouts use only the nodes that connected in the worker. A node on another network (for example, a testnet node on a mainnet install) does not connect. A full server compromise can spend the balance of the hot wallets. Keep that balance small. See [Security model](/docs/security-model/). ## Set up payouts 1. A global admin opens **Hot wallets** in the **Server** section of the dashboard menu. 2. Under **Make a hot wallet** or **Import a key**, select the family (EVM, Tron, Solana or TON) and enter a label and a fresh two-factor code. One EVM key works on every EVM network. 3. Under **Assign to a project**, select the project, the family and the hot wallet. Enter a two-factor code and click **Save**. 4. Send the native coin for network fees to the hot wallet address. Send the tokens that you plan to pay out. 5. In the project, open **Payouts** → **Limits** and set the limits. 6. Open **Payouts** → **Address book** and add the destinations for API payouts. A project without a hot wallet for a network cannot send payouts on it. The request gets `400`. ## Limits All limits are in USD, at the rate when the payout is created. A project admin sets them under **Payouts** → **Limits** with a fresh two-factor code. Each limit is 0 by default. | Limit | Effect | 0 means | |---|---|---| | **Minimum payout** | Turmite refuses a smaller payout with `400` | No minimum | | **Approve automatically up to** | A larger payout waits for an admin approval | Every payout waits for an approval | | **Hourly limit** | A payout that takes the total of the last hour above this amount waits for an approval | No limit | | **Daily limit** | A payout that takes the total of the last 24 hours above this amount waits for an approval | No limit | The totals count every payout of the project that was cleared to send in the window: `pending`, `signing`, `initiated`, `sent` and `needs-review`. They include all users and API keys. The **Limits** page shows the totals of the last hour and the last 24 hours. When no USD rate is available, the payout waits for an approval. A project with a minimum refuses the payout with `503`, because it cannot check the minimum. ## Address book The address book holds the approved destinations of the project. Only a project admin can change it, in the dashboard, with a fresh two-factor code. Each entry has a label (1 to 80 characters), a network, an address and an optional memo (up to 120 characters). An EVM address in mixed case must have a valid checksum. API payouts can only go to addresses in the address book: - The address must be an entry on the network of the ticker. - With `extra_id`, an entry with the same memo must exist. - Without `extra_id`, Turmite uses the memo of the only entry for that address. If the address has several entries and all of them have a memo, the request gets `400`. Send `extra_id`. Payouts made in the dashboard can go to any valid address. When you remove an entry, API payouts to that address stop. ## Create a payout in the dashboard A project admin can create a payout. 1. Open **Payouts** → **New payout**. 2. Select the **Currency**. Enter the **Address** or select an entry under **From the address book**. 3. Enter the **Amount**, and a **Memo** and a **Description** if necessary. 4. Enter a fresh two-factor code and click **Create payout**. Turmite checks the address for the network. It refuses an EVM address with a wrong checksum. The limits apply at once: the payout is `pending`, or `pending-approval` when a limit holds it. ## Create a payout with the API The API needs the project API key and a token for a dashboard user. The user must be a project admin of the key's project, or a global admin. On a mainnet install, the user must have two-factor authentication on. 1. Get a token. `POST /v1/auth` takes the user's email address and password. The token is valid for 5 minutes. ```bash TOKEN=$(curl -s -X POST https://pay.example.com/v1/auth \ -H "Content-Type: application/json" \ -d '{"email": "ops@example.com", "password": "..."}' | jq -r .token) ``` 2. Create a batch. A batch holds 1 to 100 withdrawals. ```bash curl -X POST https://pay.example.com/v1/payout \ -H "x-api-key: $TM_API_KEY" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "withdrawals": [ { "address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "currency": "usdttrc20", "amount": "250.00", "ipn_callback_url": "https://shop.example.com/turmite/payout-ipn", "unique_external_id": "refund-1042" } ] }' ``` ```json { "id": "5000000713", "withdrawals": [ { "id": "5000248325", "batch_withdrawal_id": "5000000713", "address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "extra_id": null, "currency": "usdttrc20", "network": "tron", "amount": "250", "fee": null, "fee_currency": "trx", "status": "pending-otp-verification", "hash": null, "error": null, "ipn_callback_url": "https://shop.example.com/turmite/payout-ipn", "payout_description": null, "unique_external_id": "refund-1042", "created_at": "2026-10-23T13:00:00.000Z", "requested_at": null, "updated_at": "2026-10-23T13:00:00.000Z" } ] } ``` The answer is `201`. New payouts have the status `pending-otp-verification` until you verify the batch. 3. Verify the batch with the current two-factor code of the token's user. ```bash curl -X POST https://pay.example.com/v1/payout/5000000713/verify \ -H "x-api-key: $TM_API_KEY" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"verification_code": "123456"}' ``` After the verification, the limits decide. A payout within the limits goes to `pending`. A payout that a limit holds goes to `pending-approval` until an admin approves it in the dashboard. The response is the batch with the new statuses. A two-factor code works one time. After 5 wrong codes, the user gets `429` for 15 minutes. On a testnet install, a user without two-factor authentication can verify without a code. ### Fields of a withdrawal | Field | Required | Description | |---|---|---| | `address` | yes | Destination. It must be in the address book | | `currency` | yes | Ticker, for example `usdttrc20` | | `amount` | yes | Amount in `currency`. It cannot have more decimals than the ticker | | `extra_id` | no | Memo, up to 120 characters. Turmite sends it as a comment on TON and as a memo on Tron | | `ipn_callback_url` | no | URL that receives the webhooks of this payout | | `payout_description` | no | Text for your records, up to 500 characters | | `unique_external_id` | no | Your idempotency ID, up to 255 characters | An `ipn_callback_url` at the top level of the request applies to each withdrawal that has none. `unique_external_id` makes a withdrawal idempotent. Send the same request again when you retry after a timeout. A second request with the same `unique_external_id` and the same ticker, address, memo and amount returns the existing payout. The same `unique_external_id` with other fields returns `409` with the code `CONFLICT`. The same `unique_external_id` twice in one request returns `400`. ### Read payouts `GET /v1/payout/{batch_id}` returns one batch. It needs the API key only. `GET /v1/payout` returns a page of payouts in `payouts`, with `limit`, `page`, `pagesCount` and `total`. It needs the API key and a token of a project member. | Parameter | Description | |---|---| | `batch_id` | Only payouts of this batch | | `status` | Only payouts with this status | | `date_from`, `date_to` | Range of `created_at`. A date or a date-time | | `order` | `asc` or `desc`. The default is `desc` | | `limit` | Payouts per page, from 1 to 500. The default is 10 | | `page` | Page number. The first page is 0 | ## The payout object | Field | Description | |---|---| | `id` | Payout ID, numeric text | | `batch_withdrawal_id` | ID of the batch, numeric text | | `address`, `extra_id` | Destination and its memo | | `currency` | Ticker | | `network` | Network code in lowercase, for example `tron` | | `amount` | Amount, a decimal string | | `fee` | Network fee in `fee_currency` as a decimal string, or `null` before Turmite knows it. The hot wallet pays it | | `fee_currency` | Native coin of the network, for example `trx` | | `status` | See [Statuses](#statuses) | | `hash` | Transaction hash, or `null` | | `error` | Error text, or `null` | | `ipn_callback_url` | URL that receives the payout webhooks | | `payout_description` | Your text | | `unique_external_id` | Your idempotency ID | | `created_at` | Time of creation | | `requested_at` | Time when a node first accepted the transaction, or `null` | | `updated_at` | Time of the last change | ## Approve a payout A payout that waits for approval has the status `pending-approval`. A project admin approves it in two steps. 1. Open **Payouts** and select the payout. 2. Under **Approve**, enter a fresh two-factor code and click **Email me a code**. Turmite emails a 6-digit code to that admin. The payout goes to `pending-otp-verification`. 3. Enter the code under **Code from the email** and click **Approve payout**. The payout goes to `pending`. The emailed code works for 10 minutes and for 5 attempts. Only the admin who received it can enter it. When the code expires or the last attempt fails, the payout goes back to `pending-approval`. To get a new code, click **Send a new code**. Another admin can start a new approval. The new code replaces the old one. Under **Stop this payout**, click **Reject** to end a payout that waits for approval or verification. Click **Cancel payout** to stop a `pending` payout before Turmite signs it. Both need a two-factor code and are final. The emailed code needs SMTP. Set `TURMITE_SMTP_URL` and `TURMITE_SMTP_FROM`. See [Configuration](/docs/configuration/). On testnet without SMTP, Turmite writes the email to the log. On mainnet without SMTP, the email cannot be sent, so held payouts cannot be approved. ## Statuses ```text API batch: pending-otp-verification --verify--> pending or pending-approval Dashboard: pending or pending-approval Approval: pending-approval -> pending-otp-verification -> pending Worker: pending -> signing -> initiated -> sent Errors: pending, signing or initiated -> failed initiated -> needs-review -> sent or failed Stop: pending-approval or pending-otp-verification -> rejected pending -> cancelled ``` [Payment statuses](/docs/statuses/#payout-statuses) describes each status. Each status change after creation sends a `payout.` webhook to the project endpoints and to the withdrawal's `ipn_callback_url`. The `ipn_callback_url` gets the flat payout object, signed with the project IPN secret. See [Webhooks](/docs/webhooks/). ## Needs review `needs-review` means that Turmite cannot tell whether the transaction happened. For example, the nonce of the hot wallet was used by a transaction that Turmite does not know. Turmite does not guess. A wrong `failed` status can cause a second payment. 1. Open the hot wallet address in a block explorer. 2. Look for a transfer of that amount to that address. 3. Open the payout in **Payouts**. Under **Resolve**, select **It was sent** or **It was not sent**. 4. Write a note of what you checked, for example the transaction hash. Enter a two-factor code and click **Save decision**. The payout becomes `sent` or `failed`. The decision is final and sends the final webhook. Do not create the payout again until you resolve it. ## Check before you send `POST /v1/payout/validate-address` checks an address for a ticker with the checks of the wallet forms: EIP-55 for mixed-case EVM addresses, the checksum and the network of Bitcoin addresses, the checksum of Tron addresses, the length of Solana keys and the network prefix of Monero addresses. Then it asks the node, when one is connected. A valid address returns `200` with the body `"OK"`. An address that is not valid returns `400` with the code `INVALID_REQUEST_PARAMS`. ```bash curl -X POST https://pay.example.com/v1/payout/validate-address \ -H "x-api-key: $TM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"address": "TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW", "currency": "usdttrc20"}' ``` `GET /v1/payout/fee` estimates the network fee. `amount` is optional. Turmite copies it to the response. ```bash curl -H "x-api-key: $TM_API_KEY" \ "https://pay.example.com/v1/payout/fee?currency=usdttrc20&amount=250" ``` ```json {"currency": "usdttrc20", "amount": "250", "fee": 1.1, "network_fee": 4.2, "fee_currency": "trx"} ``` `network_fee` is the fee in the native coin, `fee_currency`. `fee` is the same fee converted to `currency` at the USD rates. The hot wallet pays the fee in the native coin. Turmite records the real fee in the payout when it sends it. ## Good practice - Keep about one week of fees and payouts in each hot wallet. Refill it from your cold wallet. - Set a low auto-approve amount and low hourly and daily limits. - Give `project_admin` only to people who can approve payouts. - Do not use a hot wallet key in other software. Turmite must be the only sender from that address. - Keep the worker passphrase out of backups that other people can read. # Pay with any coin > Let customers pay with any coin that Trocador lists, while the payment arrives at its own deposit address in a coin that your project accepts. Source: https://turmite.com/docs/exchange/ The exchange option lets a customer pay with a coin that your project does not accept. Turmite uses [Trocador](https://trocador.app), an exchange aggregator, with your own Trocador API key. The exchange sends a coin that your project accepts to the payment's own deposit address. Turmite credits the payment when that coin arrives on chain. A project with only one wallet can accept more than 200 coins this way. For example, a project with only a USDT wallet on Tron can accept BTC, XMR and every other coin that Trocador lists. The exchange option is off by default. You turn it on per project, and it needs a Trocador API key. ## What the customer sees 1. On the invoice page, the customer clicks **Pay with another coin**. Turmite creates a payment in one of the project's coins. The payment page has the same link. It keeps the payment's coin. 2. The customer selects a coin from the list, or searches for it. Popular coins come first. The list shows 36 coins, and a search finds the others. 3. The customer can enter a **Refund address** for that coin. For a coin that uses a memo, the refund address is required, and the customer can also enter a **Refund memo**. 4. The customer clicks **Get the deposit address**. 5. The swap page shows the exact amount, the exchange's deposit address, its QR code and the memo if the coin needs one. It also shows the exchange, its KYC rating, the rate and the **Swap ID**. 6. The customer sends the coin to that address before the time on the page. 7. The status frame shows the swap in four steps. When the payment coin arrives, it shows the normal confirmation progress. The swap page has the links **Choose another coin** and **Pay directly**. When a swap for the payment is open, the coin list shows a link to it. The pages work with JavaScript off, as for a direct payment. See [Checkout](/docs/checkout/). ## The payment coin On a payment page, the swap delivers the payment's own coin. On the invoice page, Turmite selects the first coin in this list that the project accepts and that Trocador lists: `usdttrc20`, `usdtsol`, `usdcsol`, `usdcbase`, `usdtbsc`, `usdcbsc`, `usdtmatic`, `usdcmatic`, `usdc`, `usdterc20`, `btc`, `xmr`, `eth`, `ethbase`, `sol`, `trx`, `ton`, `bnbbsc`, `pol` Turmite maps each ticker to Trocador's ticker and network. It uses the first pair that is in Trocador's coin list: | Ticker | Trocador ticker and network | |---|---| | `btc` | `btc` Mainnet | | `xmr` | `xmr` Mainnet | | `eth` | `eth` ERC20, or `eth` Mainnet | | `usdc` | `usdc` ERC20 | | `usdterc20` | `usdt` ERC20 | | `ethbase` | `eth` Base, or `ethbase` Mainnet | | `usdcbase` | `usdc` Base | | `pol` | `pol` Polygon, `pol` Mainnet, or `matic` Polygon | | `usdcmatic` | `usdc` Polygon, or `usdc` MATIC | | `usdtmatic` | `usdt` Polygon, or `usdt` MATIC | | `bnbbsc` | `bnb` BEP20, or `bnb` BSC | | `usdcbsc` | `usdc` BEP20, or `usdc` BSC | | `usdtbsc` | `usdt` BEP20, or `usdt` BSC | | `trx` | `trx` Mainnet, or `trx` TRC20 | | `usdttrc20` | `usdt` TRC20 | | `sol` | `sol` Mainnet, or `sol` Solana | | `usdcsol` | `usdc` Solana, `usdc` SOL, or `usdc` SPL | | `usdtsol` | `usdt` Solana, `usdt` SOL, or `usdt` SPL | | `ton` | `ton` Mainnet, or `ton` TON | The checkout does not offer the exchange for a coin that has no pair in the list. ## How it works The `api` process sends the requests at checkout. All requests go to `https://api.trocador.app`, with your key in the `API-Key` header. 1. Turmite asks Trocador for a quote with `new_rate`. The request has the customer's coin as `ticker_from` and `network_from`, the payment coin as `ticker_to` and `network_to`, and `amount_to` set to the amount that is still due. It also has `payment=True`, `min_kycrating` and `markup`. 2. Turmite creates the swap with `new_trade`. The request has the quote ID, `fixed=True`, `payment=True`, `address` set to the payment's own deposit address, and the refund address if the customer gave one. It also has a webhook URL on your server: on the project's checkout domain when it has one, else on the site URL. 3. Trocador returns the exchange's deposit address (`address_provider`), its memo (`address_provider_memo`) and the amount to send (`amount_from`). Turmite refuses the swap when `amount_from` is less than the minimum or more than the maximum of the customer's coin in the coin list. The checkout shows the deposit address, the memo and the amount. 4. The exchange sends the payment coin to the deposit address. 5. The worker sees the transfer and counts the confirmations, as for a direct payment. 6. The payment becomes `finished`. Your shop receives the normal webhooks. `payment=True` asks for a fixed amount out. The exchange must deliver `amount_to` to the deposit address. If the customer already paid part of the amount, `amount_to` is the rest. The worker reads the status of each open swap with `trade`. It runs every 10 seconds and reads each swap at most every 20 seconds, for up to 3 days. A call from Trocador to the webhook URL makes Turmite read the status at once. Turmite does not trust the body of that call. The worker also downloads Trocador's coin list once a day. After a failed download, it waits one hour. The server must reach `https://api.trocador.app` on port 443. Allow this destination if your firewall limits outbound traffic. ## What Turmite trusts Turmite credits only the coins that arrive at the deposit address on chain. Its own nodes must see the transfer with the network's required confirmations. Turmite never uses the Trocador status to credit a payment. The status is for the checkout display only. - If less than the amount due arrives, the payment becomes `partially_paid`. - If nothing arrives before the payment window closes, the payment becomes `expired`. - If the swap fails, the payment stays unpaid. The customer gets a refund from the exchange. ## Turn it on You need the `project_admin` role and a Trocador API key. 1. Get an API key from the Trocador partner program. See [trocador.app](https://trocador.app). 2. In the dashboard, open **Settings**, then the **Exchange** tab. 3. Select **Show "Pay with another coin" on the checkout**. 4. Enter the **Trocador API key**. 5. Select **Your markup** and **Lowest KYC rating**. 6. Optional: enter **Allowed coins**. 7. Enter a fresh code in **Two-factor code** and click **Save**. On a mainnet install, Turmite checks a new key: it downloads the coin list with it. When Trocador refuses the key, Turmite does not save it. Turmite seals the API key with `TURMITE_SECRET` before it stores it in the database. The dashboard never shows the key again. To change it, enter a new key. To remove it, use **Remove the API key** with a fresh two-factor code. This turns the exchange off. ### Settings | Setting | Values | Effect | |---|---|---| | **Trocador API key** | Your key | Turmite sends it in the `API-Key` header of each Trocador request | | **Your markup** | 0, 1, 1.65 or 3 %. Default 0 | Your commission on each swap | | **Lowest KYC rating** | A, B, C or D. Default B | Trocador uses only exchanges with this rating or a better one | | **Allowed coins** | Empty, or tickers separated by commas: `btc, xmr, ltc`. Add a network with a colon: `usdt:trc20` | Empty allows every coin that Trocador lists | At 0 % markup, Trocador shares its fee with you. A higher markup gives customers worse rates. Trocador pays the commission under the terms of your Trocador account. Turmite does not record it. ### KYC rating Trocador gives each exchange a KYC rating from A to D. | Rating | Meaning | |---|---| | A | The exchange does not ask for identity checks | | B, C | Ratings between A and D. Trocador publishes the criteria | | D | The exchange can hold the customer's coins until the customer passes an identity check | A strict rating protects your customers from held funds. It can also give fewer quotes and worse rates. ## Swap statuses The status frame shows the Trocador status of the swap. The swap status does not change the payment status. Only the coins that arrive on chain change the payment status. | Swap status | The frame shows | |---|---| | `new`, `waiting` | "Waiting for your {coin}", with the time left on the quote | | `confirming` | "The exchange received your {coin}". The exchange waits for confirmations | | `sending` | "The exchange is sending" | | `finished` | "The exchange sent the coins". Turmite waits for them on the payment's network | | `failed`, `halted` | "The swap failed", with the exchange name and the swap ID | | `expired` | "The swap expired". No coins arrived in time | | `refunded` | "The swap was refunded" | After `failed`, `halted`, `expired` and `refunded`, the frame has a **Try again** button and a button for a direct payment, for example **Pay in USDT**. Trocador's "paid partially" shows as `confirming`. An unknown status shows as `waiting`. ## Refunds The exchange makes refunds. Turmite does not. A refund goes to the refund address that the customer entered. Without a refund address, the customer must contact the exchange's support to get the coins back. The customer must give the swap ID to the exchange's support. The swap page shows it. To refund a payment that Turmite received, use a payout. See [Payouts](/docs/payouts/). ## Limits - A customer can start a swap only while the payment is `waiting` or `partially_paid` and its window is open. - Turmite does not hide coins whose minimum or maximum amount does not fit. When the exchange refuses the amount, the page shows an error. The customer can choose another coin or pay directly. - Each quote has an expiry time. The customer must pay the exchange before that time. - Some coins need a memo. The checkout shows it. The customer must send it with the transfer, or the exchange cannot match the deposit. - A swap adds time. The payment window is 60 minutes by default. Set a window that leaves time for the swap and for the confirmations on both chains, in **Settings → General → Payment window (minutes)**. A deposit that arrives within one day after the window still counts. See [Payment statuses](/docs/statuses/). ## The payment object and webhooks A payment through the exchange has the normal fields. `pay_currency`, `pay_amount` and `network` describe the coin that arrives at the deposit address. They do not describe the coin that the customer sent. The API, the webhooks and the dashboard do not show the swap. Webhooks are the same as for a direct payment. Ship on `finished`. ## Testnet installs Trocador works with mainnet coins only. On a testnet install, the **Exchange** tab shows a notice, and customers do not see the exchange option. You can save the settings, but Turmite does not check the key. To test the flow, use a mainnet install and a small real amount. ## Risks - The exchange holds the customer's coins during the swap. A failed or halted swap costs the customer time or money until the exchange refunds. It costs you nothing, because Turmite credits only what arrives on chain. - An exchange with rating C or D can hold the customer's coins for an identity check. - The exchange rate and your markup change the amount that the customer pays. You receive the amount due. - Trocador and the exchange see the customer's coin, the amount, the refund address and your deposit address. - Your server sends the Trocador requests. The customer's browser does not contact Trocador. # Tickers and networks > Supported networks, tickers, token contracts, chain IDs and default confirmations. Source: https://turmite.com/docs/networks/ Turmite supports nine networks. Each install runs in one mode, set with `TURMITE_MODE`: `mainnet` or `testnet`. The mode decides which networks Turmite creates in the database. A database keeps its mode: Turmite does not start on it with the other mode. ## Networks | Code | Network | Testnet | Family | Assets | |---|---|---|---|---| | `BTC` | Bitcoin | Signet (regtest for local tests) | bitcoin | BTC | | `XMR` | Monero | Stagenet | monero | XMR | | `ETH` | Ethereum | Sepolia | evm | ETH, USDC, USDT | | `BASE` | Base | Base Sepolia | evm | ETH, USDC | | `POLYGON` | Polygon | Amoy | evm | POL, USDC, USDT | | `BSC` | BNB Smart Chain | BSC testnet | evm | BNB, USDC, USDT | | `TRON` | Tron | Nile | tron | TRX, USDT | | `SOL` | Solana | Devnet | solana | SOL, USDC, USDT | | `TON` | TON | Testnet | ton | TON | Bitcoin and Monero payments go straight to your own wallet. On the other networks, Turmite sweeps each deposit to your address. See [Wallets per chain](/docs/wallets/#custody-per-chain). ## Tickers Use these codes in `pay_currency` and in payout `currency`. They are the codes of the common hosted-gateway API. | Network | Ticker | Asset | Decimals | |---|---|---|---| | `BTC` | `btc` | BTC | 8 | | `XMR` | `xmr` | XMR | 12 | | `ETH` | `eth` | ETH | 18 | | `ETH` | `usdc` | USDC | 6 | | `ETH` | `usdterc20` | USDT | 6 | | `BASE` | `ethbase` | ETH | 18 | | `BASE` | `usdcbase` | USDC | 6 | | `POLYGON` | `pol` | POL | 18 | | `POLYGON` | `usdcmatic` | USDC | 6 | | `POLYGON` | `usdtmatic` | USDT | 6 | | `BSC` | `bnbbsc` | BNB | 18 | | `BSC` | `usdcbsc` | USDC | 18 | | `BSC` | `usdtbsc` | USDT | 18 | | `TRON` | `trx` | TRX | 6 | | `TRON` | `usdttrc20` | USDT | 6 | | `SOL` | `sol` | SOL | 9 | | `SOL` | `usdcsol` | USDC | 6 | | `SOL` | `usdtsol` | USDT | 6 | | `TON` | `ton` | TON | 9 | `GET /v1/currencies` returns the tickers that your project accepts. See [Merchant API](/docs/merchant-api/#status-and-currencies). A global admin can turn each ticker on or off, and add a token contract, on the **Networks** page. See [Networks and nodes](/docs/nodes/#tokens). ## EVM chain IDs | Network | Mainnet chain ID | Testnet | Testnet chain ID | |---|---|---|---| | Ethereum | 1 | Sepolia | 11155111 | | Base | 8453 | Base Sepolia | 84532 | | Polygon | 137 | Amoy | 80002 | | BNB Smart Chain | 56 | BSC testnet | 97 | Turmite refuses an EVM node whose chain ID is different. ## Token contracts on mainnet | Ticker | Contract | |---|---| | `usdc` | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | | `usdterc20` | `0xdAC17F958D2ee523a2206206994597C13D831ec7` | | `usdcbase` | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | | `usdcmatic` | `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` | | `usdtmatic` | `0xc2132D05D31c914a87C6611C10748AEb04B58e8F` | | `usdcbsc` | `0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d` | | `usdtbsc` | `0x55d398326f99059fF775485246999027B3197955` | | `usdttrc20` | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` | | `usdcsol` | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` | | `usdtsol` | `Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB` | ## Tokens on testnet A testnet install has fewer tokens: | Network | Testnet tokens | |---|---| | Ethereum Sepolia | `usdc` (`0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`) | | Base Sepolia | `usdcbase` (`0x036CbD53842c5426634e7929541eC2318f3dCF7e`) | | Polygon Amoy | `usdcmatic` (`0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582`) | | BNB Smart Chain testnet | None | | Tron Nile | `usdttrc20` (`TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf`) | | Solana Devnet | `usdcsol` (`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`) | The catalog in `crates/turmite/src/chains/catalog.rs` holds the networks, chain IDs and token contracts for each mode. Turmite writes it to the database on the first start. Later starts keep the changes that admins made. ## Confirmations A deposit counts when it has the confirmation count of its network. A global admin changes the count on the **Networks** page. See [Networks and nodes](/docs/nodes/#confirmations). | Network | Mainnet default | Testnet default | |---|---|---| | Bitcoin | 2 | 1 | | Monero | 10 | 10 | | Ethereum | 12 | 3 | | Base | 10 | 3 | | Polygon | 64 | 3 | | BNB Smart Chain | 15 | 3 | | Tron | 19 | 1 | | Solana | 32 | 1 | | TON | 1 | 1 | ## Network notes - Bitcoin: deposit addresses come from a BIP84 account key (`xpub` or `zpub` on mainnet, `tpub` or `vpub` on the test networks). Check the first addresses against your wallet. See [Wallets per chain](/docs/wallets/#bitcoin). - Monero: each payment gets a subaddress of your wallet. Outputs unlock after 10 blocks. - EVM: the deposit contract does not exist on the chain until the first sweep deploys it. The address can receive funds before that. - Tron: a deposit counts only when its block is solidified. - Solana: a `finalized` transaction counts as 32 confirmations. A `confirmed` transaction counts as 1 to 31. - TON: a block is final when the masterchain refers to it, and the node lists only such transactions. Thus a deposit counts as confirmed when the node lists it, and the default count is 1. Deposit addresses are wallets that are not yet deployed. Turmite shows them in the non-bounceable form (`UQ…` on mainnet, `0Q…` on testnet). The first sweep deploys the wallet. # Errors > The error format of the merchant API, the error codes, and when to retry a request. Source: https://turmite.com/docs/errors/ The merchant API returns errors as JSON with an HTTP status code. The format is the format of the common hosted-gateway API, so existing error handling works. ## Format ```json { "status": false, "statusCode": 400, "code": "INVALID_REQUEST_PARAMS", "message": "`price_amount` must be greater than 0." } ``` | Field | Description | |---|---| | `status` | Always `false` for an error | | `statusCode` | The HTTP status code | | `code` | A stable code. Use it in your code | | `message` | A text for people. It can change. Do not parse it | ## Codes | HTTP | `code` | Cause | |---|---|---| | 400 | `INVALID_REQUEST_PARAMS` | A field is missing or not valid, the body is not valid JSON, the ticker is not enabled for the project, the amount is below the minimum, or the two-factor code is wrong. The `message` tells which | | 401 | `AUTH_REQUIRED` | The API key is missing or wrong, the payout token is missing, wrong or expired, or the email or password for `POST /v1/auth` is wrong | | 403 | `FORBIDDEN` | The user of the payout token is not a member of the key's project, is not a project admin, or has no two-factor authentication on a mainnet install | | 404 | `NOT_FOUND` | The path does not exist, or the payment, invoice or payout does not exist or belongs to another project | | 405 | `METHOD_NOT_ALLOWED` | The path exists, but not with this HTTP method | | 409 | `CONFLICT` | The request conflicts with an existing object, for example a `unique_external_id` that is used with other fields | | 429 | `TOO_MANY_REQUESTS` | Too many requests or too many wrong codes. Wait and retry | | 500 | `INTERNAL_ERROR` | An error on the server. The message is `Internal error.` The server log has the details | | 503 | `UNAVAILABLE` | A service that the request needs is not available: the exchange rates, a free deposit address or a node of the network | Treat `code` as an open set. Later versions can add values. Handle an unknown code by its HTTP status. ## Rate limits | Limit | Value | |---|---| | Merchant API, per client IP address | 600 requests per minute | | Checkout pages, per client IP address | 300 requests per minute | | `POST /v1/auth`, per IP address and per email address | 10 attempts per minute | | Wrong two-factor codes in `POST /v1/payout/{batch_id}/verify`, per user | 5, then the user is locked for 15 minutes | A request above a limit gets `429`. Turmite sends no `Retry-After` header with a `429`. ## When to retry | Response | Action | |---|---| | `2xx` | Done | | `400`, `401`, `403`, `404`, `405`, `409` | Do not retry. Fix the request or the configuration | | `429` | Retry after a delay, for example 10 s | | `503` with `Retry-After: 30` | Retry after 30 s. There was no free deposit address, or the network has no connected node | | `500`, `502`, `503`, `504` | Retry with backoff, for example after 1 s, 5 s and 30 s | | Timeout or connection error | Read the state before you retry. See below | ### Timeouts on POST requests A timeout does not tell you if Turmite created the object. - `POST /v1/payment`: send `idempotency_key` with the request. Send the same request again after a timeout. Turmite returns the first payment. - `POST /v1/invoice`: you can create a second invoice for the same order. The customer pays one of them. The unpaid one expires. Use the newest `invoice_url`. - `POST /v1/payout`: give each withdrawal a `unique_external_id`. Send the same request again. Turmite returns the existing payout. Do not create a payout without one. See [Payouts](/docs/payouts/#create-a-payout-with-the-api). ## Log errors safely - Log `statusCode`, `code` and `message`. - Do not log the `x-api-key` header, the payout token or the request body of `POST /v1/auth`. ## Errors on your webhook endpoint Turmite treats every response other than `2xx` from your endpoint as a failure and retries. See [Webhooks](/docs/webhooks/#retries). Return `401` when the signature is wrong. Return `2xx` for an event that you already processed. A `5xx` response causes a retry. # TypeScript SDK > Call the merchant API and verify webhooks from TypeScript or JavaScript with the @turmite/sdk package. Source: https://turmite.com/docs/sdk/ `@turmite/sdk` is the TypeScript client for the merchant API. It also verifies webhooks. It has no runtime dependencies. The source is in `sdk/typescript` in the repository. ## Install ```bash npm install @turmite/sdk ``` The SDK uses `fetch` and Web Crypto. It runs on Node.js 20 or later, Deno, Bun and Cloudflare Workers. It is an ES module. Use the SDK on your server only. The API key must not reach a browser. ## Create a client ```ts import { Turmite } from '@turmite/sdk'; const turmite = new Turmite({ baseUrl: 'https://pay.example.com', apiKey: process.env.TM_API_KEY!, }); ``` | Option | Description | |---|---| | `baseUrl` | Your site URL, with its path if it has one. A checkout domain also works | | `apiKey` | The project API key (`tm_…`) | | `fetch` | A replacement for the global `fetch` | | `timeoutMs` | Request timeout in milliseconds. The default is 30000 | ## Create an invoice ```ts const invoice = await turmite.createInvoice({ price_amount: 25, price_currency: 'usd', order_id: 'ORDER-1001', order_description: 'Order 1001', ipn_callback_url: 'https://shop.example.com/turmite/ipn', success_url: 'https://shop.example.com/orders/ORDER-1001', cancel_url: 'https://shop.example.com/cart', }); // Send the customer to the checkout. redirect(invoice.invoice_url); ``` ## Create a payment ```ts const payment = await turmite.createPayment({ price_amount: 25, price_currency: 'usd', pay_currency: 'usdttrc20', order_id: 'ORDER-1001', ipn_callback_url: 'https://shop.example.com/turmite/ipn', idempotency_key: 'ORDER-1001', }); // Show these on your own page, or send the customer to payment.payment_url. console.log(payment.pay_address, payment.pay_amount); const latest = await turmite.getPayment(payment.payment_id); ``` The server sends `payment_id` as a string in the `createPayment()` response and as a number in `getPayment()` and `listPayments()`. See [The payment object](/docs/merchant-api/#the-payment-object). ## Methods | Method | Endpoint | |---|---| | `status()` | `GET /v1/status` | | `currencies()` | `GET /v1/currencies` | | `fullCurrencies()` | `GET /v1/full-currencies` | | `merchantCoins()` | `GET /v1/merchant/coins` | | `minAmount({ currency_from, currency_to, fiat_equivalent? })` | `GET /v1/min-amount` | | `estimate({ amount, currency_from, currency_to })` | `GET /v1/estimate` | | `createPayment(body)` | `POST /v1/payment` | | `getPayment(paymentId)` | `GET /v1/payment/{payment_id}` | | `listPayments(params)` | `GET /v1/payment` | | `createInvoice(body)` | `POST /v1/invoice` | | `createInvoicePayment(body)` | `POST /v1/invoice-payment` | | `balance()` | `GET /v1/balance` | | `auth(email, password)` | `POST /v1/auth` | | `createPayout(body, options?)` | `POST /v1/payout` | | `verifyPayout(batchId, verificationCode, options?)` | `POST /v1/payout/{batch_id}/verify` | | `getPayout(batchId)` | `GET /v1/payout/{batch_id}` | | `listPayouts(params, options?)` | `GET /v1/payout` | | `validateAddress(body)` | `POST /v1/payout/validate-address` | | `payoutFee({ currency, amount? })` | `GET /v1/payout/fee` | The field names are the same as in the API. See [Merchant API](/docs/merchant-api/). ## Payouts `auth()` signs in a dashboard user and keeps the token for `createPayout()`, `verifyPayout()` and `listPayouts()`. The token is valid for 5 minutes. To use another token, pass `{ token }` as the last argument of these methods. `createPayout()` and `verifyPayout()` need a user who is a project admin. ```ts await turmite.auth('ops@example.com', password); const batch = await turmite.createPayout({ withdrawals: [ { address: 'TQrZ9wBzhXnHdAdKwRBfdMH2RiTjH6yGtW', currency: 'usdttrc20', amount: '250.00', unique_external_id: 'refund-1042' }, ], }); await turmite.verifyPayout(batch.id, totpCode); ``` See [Payouts](/docs/payouts/). ## Verify webhooks The SDK verifies both signatures. Pass the raw body, before JSON parsing. A string, a `Uint8Array` (for example a Node.js `Buffer`) or an `ArrayBuffer` works. | Function | Checks | Returns | |---|---|---| | `verifyWebhook(rawBody, headers, secret, options?)` | `x-turmite-timestamp` and `x-turmite-signature` | The parsed body. Throws `WebhookVerificationError` when a check fails | | `verifyIpnSignature(rawBody, signature, secret)` | The key-sorted HMAC-SHA512 signature, default header `x-turmite-sig` | `true` or `false`. It never throws for a bad signature or body | | `isPaid(status)` | Nothing | `true` only for `finished` | `verifyWebhook` refuses a timestamp that is more than 300 seconds from the local clock. Change it with `{ toleranceSeconds }`. `verifyIpnSignature` makes the key-sorted JSON from the raw body and keeps the text of each number, so the check also works for a number such as `1e-6`. `verifyWebhook` throws a `TypeError` when the body is not raw, for example a parsed object. ```ts import express from 'express'; import { isPaid, verifyIpnSignature, verifyWebhook, type PaymentWebhook, type WebhookEvent } from '@turmite/sdk'; const app = express(); // Per-payment IPN (ipn_callback_url): timestamp signature, project IPN secret. app.post('/turmite/ipn', express.raw({ type: 'application/json' }), async (req, res) => { let payment: PaymentWebhook; try { payment = await verifyWebhook(req.body, req.headers, process.env.TM_IPN_SECRET!); } catch { return res.sendStatus(400); } if (isPaid(payment.payment_status)) await markOrderPaid(payment.order_id, payment.payment_id); res.sendStatus(200); }); // Project endpoint: envelope body, signed with the endpoint's own secret. app.post('/turmite/webhook', express.raw({ type: 'application/json' }), async (req, res) => { const event = await verifyWebhook(req.body, req.headers, process.env.TM_WEBHOOK_SECRET!).catch(() => null); if (!event) return res.sendStatus(400); if (event.event === 'payment.finished') await markOrderPaid(event.data.order_id, event.data.payment_id); res.sendStatus(200); }); // Existing code that checks the compatible signature. app.post('/legacy/ipn', express.raw({ type: 'application/json' }), async (req, res) => { const ok = await verifyIpnSignature(req.body, req.get('x-turmite-sig'), process.env.TM_IPN_SECRET!); if (!ok) return res.sendStatus(400); res.sendStatus(200); }); ``` Make `markOrderPaid` idempotent. See [Webhooks](/docs/webhooks/#handle-events-safely). The package also exports the header names as `TIMESTAMP_HEADER`, `SIGNATURE_HEADER` and `IPN_SIGNATURE_HEADER`, and `canonicalJson()`, which returns the key-sorted JSON of a raw body. ## Errors A failed request throws `TurmiteError`. It has `status` (the HTTP status, or `0` when no response came), `code`, `message` and `body`. ```ts import { TurmiteError } from '@turmite/sdk'; try { await turmite.createPayment({ price_amount: 0, price_currency: 'usd', pay_currency: 'btc' }); } catch (err) { if (err instanceof TurmiteError) { console.error(err.status, err.code, err.message); } } ``` `code` is the [API error code](/docs/errors/), for example `INVALID_REQUEST_PARAMS`. The SDK adds these codes of its own: | Code | Cause | |---|---| | `NETWORK_ERROR` | The request failed before a response came | | `TIMEOUT` | No response within `timeoutMs` | | `TOKEN_REQUIRED` | `createPayout()`, `verifyPayout()` or `listPayouts()` was called before `auth()` and without a token | | `HTTP_` | An error response without a JSON `code` | `WebhookVerificationError` has a `code`: `missing_header`, `invalid_timestamp`, `timestamp_out_of_range`, `invalid_signature` or `invalid_body`. ## Types The package exports a type for each request and response, for example `Payment`, `PaymentWebhook`, `PaymentStatus`, `Invoice`, `Payout`, `PayoutStatus`, `PayoutBatch` and `WebhookEvent`. It also exports the option types `TurmiteOptions`, `TokenOptions`, `FetchLike`, `VerifyWebhookOptions`, `HeadersLike`, `RawBody` and `WebhookErrorCode`. The types follow the [API reference](/docs/api-reference/). # WooCommerce plugin > Install the Turmite payment gateway plugin for WooCommerce and connect it to your Turmite server. Source: https://turmite.com/docs/woocommerce/ The WooCommerce plugin adds Turmite as a payment method in your store. The customer pays on the Turmite checkout. A signed IPN updates the order. The source is in `plugins/woocommerce` in the repository. ## Requirements - WordPress 6.5 or later. - WooCommerce 9.0 or later. - PHP 8.1 or later. - A Turmite server that your store can reach over `https`. - An API key and the IPN secret of a Turmite project. Plugin version 0.1.0 is tested with WordPress 7.1 and WooCommerce 11.2. The plugin works with the classic checkout, the Cart and Checkout blocks, and High-Performance Order Storage (HPOS). ## Install 1. Download `turmite-woocommerce.zip` from the latest release on [GitHub](https://github.com/loruorg/turmite/releases). 2. In WordPress, open **Plugins** → **Add New Plugin** → **Upload Plugin**. 3. Select the zip file and click **Install Now**. 4. Click **Activate**. To build the zip from source, run `./build.sh` in `plugins/woocommerce`. The script writes `dist/turmite-woocommerce.zip`. ## Connect the plugin 1. In Turmite, create an API key for the project. See [Merchant API](/docs/merchant-api/#authentication). 2. Copy the project IPN secret. See [Webhooks](/docs/webhooks/#the-secrets). 3. In WordPress, open **WooCommerce** → **Settings** → **Payments** → **Turmite**. 4. Fill in the settings and save. Customers see the payment method only when **Turmite URL**, **API key** and **IPN secret** are set. | Setting | Value | |---|---| | Enable | On ("Accept crypto payments through Turmite") | | Title | Name of the payment method at checkout. The default is "Crypto" | | Description | Text under the payment method at checkout | | Turmite URL | Your Turmite site URL, for example `https://pay.example.com`. It can have a path. It must start with `https://` or `http://` | | API key | The project API key (`tm_…`) | | IPN secret | The project IPN secret | | Signature header | `x-turmite-sig` by default. Change it only if you changed **IPN signature header** in Turmite **Settings** | | Order status on payment | The order status when the payment is `finished`: WooCommerce default, Processing, Completed or On hold | | Debug log | Writes a debug log in **WooCommerce** → **Status** → **Logs**, source `turmite`. The plugin always logs errors. The log contains no secrets | ## How an order is paid 1. The customer selects Turmite at checkout and places the order. 2. The plugin creates an invoice with `POST /v1/invoice`. It sends the WooCommerce order ID as `order_id`, `Order #` as `order_description`, the order total and the store currency as the price, the IPN URL, `success_url` (the order received page) and `cancel_url` (the checkout page). 3. The plugin sends the customer to `invoice_url`. 4. The customer pays on the Turmite checkout. 5. Turmite sends a signed IPN to the store at each status change. 6. The plugin checks the signature in the signature header. When `x-turmite-timestamp` and `x-turmite-signature` are present, it also checks them and refuses a timestamp more than 300 seconds from its clock. Then it updates the order. 7. The "Return to {store}" button opens the order received page. The IPN URL is `https://shop.example.com/wp-json/turmite/v1/ipn`. The plugin settings page shows it. - The plugin ignores a repeated IPN for the same payment and status. A `finished` IPN with a new `actually_paid` only adds a note. - When the price, the currency or the invoice of an IPN does not agree with the order, the plugin puts an unpaid order on hold and adds a note. - An IPN for an order that does not exist gets `404`, so Turmite retries it. - An IPN for an older payment of the same order changes the order only when it is `finished`. ## Order status mapping | Turmite status | WooCommerce order | |---|---| | `waiting`, `confirming`, `confirmed` | No change. The plugin adds an order note | | `partially_paid` | On hold, with a note of the amount received. A paid order only gets a note | | `finished` | Paid. The status is the one in **Order status on payment**. An order that is already paid only gets a note. The note warns of a double payment when another payment of the order finished | | `expired` | Cancelled, if the order is still pending | | `refunded` | Refunded, if the order is paid or on hold | | `failed` | Failed, if the order is still pending. Turmite does not set this status now | With **WooCommerce default**, a paid order becomes Processing, or Completed when all items are virtual and downloadable. ## Test the plugin 1. Connect the store to a Turmite install with `TURMITE_MODE=testnet`. 2. Place an order and pay with testnet coins. 3. Check that the order gets the status of **Order status on payment**, by default Processing. 4. In Turmite, open **Webhooks** and check the response code under **Deliveries**. On mainnet, the IPN URL must be a public `https` URL. Turmite does not follow redirects, so the URL must answer directly. ## Troubleshooting | Problem | Check | |---|---| | The order stays Pending payment | Open **Webhooks** in Turmite. Look at the response code of the IPN under **Deliveries** | | The IPN gets `401` | The IPN secret or the signature header in the plugin does not match Turmite, or the clock of the store is wrong | | The IPN gets `404` | The order does not exist in this store | | The IPN gets `301` or `302` | The store redirects the IPN URL. Use the final URL, for example with `https://` and the right `www` prefix | | The checkout shows an error | The base URL or the API key is wrong, or the ticker is not enabled for the project | # Configuration > Set secrets and per-deployment values in the environment, and set everything else in the dashboard. Source: https://turmite.com/docs/configuration/ Turmite reads a short list of environment variables at start. Every other setting is in the database, and you edit it in the dashboard. ## Where settings live | Kind | Where | Who edits it | |---|---|---| | Secrets and per-deployment values | Environment (`/opt/turmite/.env`) | The server operator | | Networks, RPC nodes, confirmations, tokens | Database | Root and admin users, in **Server → Networks** | | Hot wallets, users, update checks | Database | Root and admin users | | Checkout domain of a project | Database | Root and admin users | | Wallets, API keys, payouts, branding, exchange | Database, per project | Project admins | | Webhook endpoints | Database, per project | Developers and project admins | `turmite init` writes the `.env` file and the `secrets/` folder. The install script runs it for you. See [Install on a server](/docs/install/). ## Environment variables [Environment variables](/docs/environment/) lists each variable with its default. Turmite has no variable that turns a feature on or off. `turmite init` writes this file. The install script then sets `TURMITE_VERSION` to the release that it installs. ```dotenv # Written by `turmite init`. Keep a copy offline with secrets/. # Image tag that docker compose runs. install.sh --upgrade changes it. TURMITE_VERSION=0.1.0 TURMITE_MODE=mainnet TURMITE_SITE_URL=https://pay.example.com TURMITE_SECRET=3f9c...64 hex characters...a1 TURMITE_HOT_RECIPIENT=age1qz... TURMITE_HOT_IDENTITY_FILE=/secrets/hot-identity.age TURMITE_HOT_PASSPHRASE_FILE=/secrets/hot_passphrase POSTGRES_PASSWORD=0b6c...32 hex characters... TURMITE_DATABASE_URL=postgres://turmite:0b6c...@postgres:5432/turmite # Caddy site address: the host name gets automatic TLS; http://:80 is plain HTTP. TURMITE_CADDY_ADDRESS=pay.example.com # Proxies in front of Caddy (for example Cloudflare), with the private ranges, space-separated. # TURMITE_TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 173.245.48.0/20 ... # Optional: email for payout approvals and security notices. # TURMITE_SMTP_URL=smtps://user:password@smtp.example.com:465 # TURMITE_SMTP_FROM=Turmite ``` `TURMITE_VERSION`, `POSTGRES_PASSWORD` and `TURMITE_CADDY_ADDRESS` are for Docker Compose and Caddy. `TURMITE_CADDY_ADDRESS` is the host name of an `https://` site URL. With a path in the site URL, with `http://`, or with a `localhost`, `.localhost` or `.local` host, it is `http://:80`. The compose file gives each service only the variables that it needs. The `worker` gets the identity and passphrase variables and mounts `secrets/`. The `api` gets `TURMITE_HOT_RECIPIENT` and `TURMITE_TRUSTED_PROXIES`. It never receives the identity or the passphrase. ## Site URL - Use the URL that customers see. Checkout links and the merchant API base URL come from it. - Use `https://` on mainnet. Turmite refuses to start on mainnet with an `http://` site URL. - Session cookies get the `Secure` flag on `https://` URLs. - A path is allowed. See [Run under a path](/docs/path-prefix/). - Dashboard forms must carry an `Origin` (or `Referer`) header that matches the origin of this URL. ## Mode `TURMITE_MODE` selects the networks that Turmite seeds on first start. A `testnet` install uses Bitcoin Signet, Monero Stagenet, Ethereum Sepolia, Base Sepolia, Polygon Amoy, BNB Smart Chain Testnet, TRON Nile, Solana Devnet and TON Testnet. A `mainnet` install uses the main networks. See [Networks and nodes](/docs/nodes/). Turmite stores the mode in the database. It refuses to start when `TURMITE_MODE` differs from the stored mode. To go from testnet to mainnet, make a new install with a new database. ## Hot wallet keys Turmite uses [age](https://age-encryption.org) to protect hot keys and the deposit seed. - The `api` has only the public key (`TURMITE_HOT_RECIPIENT`). It can encrypt keys. It cannot decrypt them. - The `worker` has the identity file and the passphrase. It is the only process that can sign. By default the passphrase is stored in `secrets/hot_passphrase`, so the worker can restart without you. To give it at each start instead, see [Run without a stored passphrase](/docs/install/#run-without-a-stored-passphrase). ## Email Turmite sends payout approval codes and security advisory notices by email. On mainnet, set `TURMITE_SMTP_URL` and `TURMITE_SMTP_FROM` before you approve a payout. Any SMTP provider works. See [TURMITE_SMTP_URL](/docs/environment/#turmite_smtp_url). On testnet, SMTP is optional. Without it, Turmite writes each email to the log, with a warning. On mainnet without SMTP, Turmite starts, but each approval code fails. ## Settings in the dashboard These settings are in the database. They do not need a restart. | Setting | Page | Scope | |---|---|---| | Project name, payment window, invoice window | Settings → General | Project | | IPN signature header, IPN secret | Settings → General | Project | | Logo, theme, accent colour, message, support links, return URL, allowed return hosts | Settings → Checkout | Project | | Trocador API key, markup, lowest KYC rating, allowed coins | Settings → Exchange | Project | | Checkout domain | Settings → Domain | Project | | Wallets per network | Wallets | Project | | API keys | API keys | Project | | Webhook endpoints | Webhooks | Project | | Payout limits | Payouts → Limits | Project | | Address book | Payouts → Address book | Project | | Members and project roles | Team | Project | | Networks, nodes, confirmations, tokens | Server → Networks | Install | | Hot wallets | Server → Hot wallets | Install | | Users | Server → Users | Install | | Audit log | Server → Audit log | Install | | Update checks | Server → Updates | Install | The **Server** section of the menu shows only for root and admin users. Settings that move or redirect money need a fresh TOTP code. See [Dashboard and roles](/docs/dashboard/#two-factor-authentication). # Dashboard and roles > Sign in to the dashboard, give each user the lowest role that works, and protect accounts with two-factor codes. Source: https://turmite.com/docs/dashboard/ The dashboard is at `/dashboard` under your site URL. The server renders every page, and every action is an HTML form. It works with JavaScript off. ## First sign-in 1. Open `https://pay.example.com/dashboard/setup`. 2. Enter an email address and a password of 12 characters or more. Enter the password again. 3. Click **Create account**. This account becomes the `root` user. There is only one `root` user. If the install has no project, Turmite also makes a project called "My shop". 4. Open **Account** and turn on two-factor authentication. After sign-in, Turmite opens your first project. An admin with no project goes to **New project**. A member with no project goes to **Account**. The setup page works only while the database has no users. After that, sign in at `/dashboard/login`. ## Pages The **Project** menu at the top of the sidebar switches between projects. Admins see **New project** there. Each project has these pages, in this order: | Page | Use | |---|---| | Overview | Setup steps, totals of the last 30 days and recent payments. A warning lists payments whose sweep is late or failed | | Payments | List and detail of payments. The detail page shows each sweep with its status, and has **Mark as paid** and **Mark as refunded** | | Invoices | Invoices, and **New payment link** to make one by hand | | Wallets | Settlement setup per network, and **Deploy now** for the EVM factory. See [Wallets per chain](/docs/wallets/) | | Payouts | Tabs **Payouts**, **New payout**, **Address book** and **Limits**. See [Payouts](/docs/payouts/) | | API keys | Create and revoke keys for the merchant API | | Webhooks | Endpoints with **Send test** and **Delete**, and deliveries with **Retry**. See [Webhooks](/docs/webhooks/) | | Settings | Tabs **General** (name, payment and invoice windows, IPN signature header, IPN secret), **Checkout** (logo, accent colour, theme, message, support contacts, return URL and hosts), **Exchange** (Trocador) and **Domain** (checkout domain) | | Team | Project members and their roles | Global admins also see a **Server** section in the sidebar: | Page | Use | |---|---| | Server → Networks | Per network: on or off, confirmations, nodes with status, height and the node in use, last block, assets and tokens | | Server → Hot wallets | Make or import hot wallets, see their balances, assign them to projects | | Server → Users | Add users, set the global role, disable users and reset two-factor | | Server → Audit log | Every sign-in and sensitive action, 100 entries per page | | Server → Updates | The installed version, the latest release, security advisories, **Check now** and the daily check setting | ## Roles Each user has one global role. Members also have one role in each project they belong to. ### Global roles | Role | Access | |---|---| | `root` | Everything. The first user. Only `root` adds admins, changes global roles, disables admins and resets two-factor for other users | | `admin` | Every project with the `project_admin` role, plus new projects, checkout domains, networks, nodes, hot wallets, users, the audit log and updates | | `member` | Only the projects the user belongs to, with that project's role | ### Project roles | Role | Access | |---|---| | `project_admin` | Everything in the project except the checkout domain: settings, IPN secret rotation, wallets, creating API keys, team, **Mark as paid** and **Mark as refunded**, payouts, approvals, the address book and payout limits | | `developer` | Create payment links, add and delete webhook endpoints, send tests, retry deliveries, revoke API keys, show the IPN secret. Read everything else | | `read_only` | Read access to the project | Give each person the lowest role that works. Use `developer` for integration work and `read_only` for support staff. Give `project_admin` only to people who can approve payouts. ## Two-factor authentication Turmite uses TOTP codes from an authenticator app. 1. Open **Account**. The link is your email address at the foot of the sidebar. 2. Under **Two-factor authentication**, click **Set up two-factor**. 3. Scan the QR code with an authenticator app, or enter the key that the page shows. 4. Enter the code that the app shows, and click **Turn on**. To turn two-factor off, enter a current code and click **Turn off**. On mainnet, Turmite refuses sensitive actions while two-factor is off. On testnet, those actions work without a code until you turn two-factor on. After you turn on two-factor authentication, Turmite asks for a code at each sign-in. ### Actions that need a fresh code A sensitive action needs a new code, also during an active session: - creating API keys; - showing or rotating the IPN secret; - saving a wallet, and **Deploy now**; - saving the exchange settings, and removing the Trocador key; - setting the checkout domain; - creating, approving, rejecting, cancelling and resolving payouts; - adding and removing address book entries; - saving payout limits; - **Mark as paid** and **Mark as refunded** on a payment; - making, importing and assigning hot wallets; - adding or removing an RPC node, lowering the confirmations of a network, turning assets on or off, adding a token; - adding a user, making a user an admin, and resetting two-factor for a user; - adding a project admin to a team; - turning off update checks; - turning two-factor off for your own account. These actions need no code: revoking an API key, raising confirmations, turning a network on or off, stopping or starting payments on a network, the **General** and **Checkout** settings, other team changes, making an admin a member, disabling users, webhook endpoints and payment links. A new password needs the current password. Each code works one time. After 5 wrong codes, Turmite locks two-factor checks for that user for up to 15 minutes. The lock is in memory. It ends early when the `api` process restarts. ### Lost authenticator The `root` user can reset two-factor for another user under **Server → Users**, with a fresh code. Without the root account, a person with shell access to the server can clear two-factor for a user: ```bash cd /opt/turmite sudo docker compose exec api turmite user reset2fa user@example.com ``` The command also signs the user out of every session. The user then signs in with the password and enrols again. The audit log records the reset as `user.2fa_reset`. ## Sign-in protection - Passwords have at least 12 characters. - Turmite hashes passwords with Argon2id (64 MiB, 3 passes). At most four hashes run at one time. - Sign-in is limited to 10 attempts per minute per IP address and per email address. - Sign-in with two-factor has two steps: the password at `/dashboard/login`, then the code at `/dashboard/login/code`. The second step must follow within 5 minutes. - A disabled user cannot sign in, and the user's sessions end. - A password change ends the other sessions of the user. The session that changed the password stays. - Turmite takes the client IP from `X-Forwarded-For` only when the request comes from `TURMITE_TRUSTED_PROXIES`. See [Reverse proxy, Cloudflare and firewall](/docs/reverse-proxy/#client-ip-addresses). ## Sessions | Property | Value | |---|---| | Cookie name | `turmite_session` | | Flags | `HttpOnly`, `SameSite=Strict`, `Secure` on `https://` | | Path | The path of the site URL, for example `/` or `/pay` | | Lifetime | 12 hours without activity. Each request extends it, up to 7 days after sign-in | Each dashboard `POST` request must carry an `Origin` header, or a `Referer` header, with the origin of the site URL. Turmite refuses other requests with `403 Cross-site request refused.` A proxy that changes `Origin` breaks the dashboard. See [Private dashboard](/docs/private-dashboard/) for a private entrance that keeps this check. ## Audit log Turmite writes an audit entry for each sign-in and each sensitive action, for example two-factor changes, API keys, the IPN secret, project settings, wallets, hot wallets, payouts, networks, nodes, manual payment changes and user changes. The worker adds `node.failover` when it moves a network to another node. Each entry has the time, the user or `system`, the project, the action, the target and the IP address. Global admins read it in **Server → Audit log**. Project admins do not see the audit log. ## Update checks A minute after start and then once a day, the worker reads the latest release and the security advisories of `loruorg/turmite` on GitHub. The request sends the version in its `User-Agent` and nothing else. When a newer release exists, admins see a banner with **Dismiss**. When an advisory affects the installed version, admins see a red banner that stays until the server runs a fixed version. When SMTP is set, admins also get one email for each critical or high advisory. The check is on by default. To turn it off, open **Server → Updates**, clear **Check for updates every day**, enter a two-factor code and click **Save**. If you turn it off, watch the [releases](https://github.com/loruorg/turmite/releases) and [advisories](https://github.com/loruorg/turmite/security/advisories) yourself. To install an update, see [Upgrades](/docs/upgrades/). # Projects and checkout domains > Run several shops on one install, and give each shop its own checkout domain. Source: https://turmite.com/docs/projects/ One install serves one operator. The operator can have many projects. A project is one shop or one business. ## What belongs to a project | Per project | Shared by the install | |---|---| | Wallets per network | Users and global roles | | API keys | Networks, RPC nodes, confirmations, tokens | | Webhook endpoints and the IPN secret | Hot wallets (each project has its own assignment) | | Branding and checkout settings | The deposit seed | | Exchange settings | The exchange coin list | | Checkout domain | Update checks | | Payout limits and address book | Audit log | | Team members and project roles | | An API key works only for its own project. A payment always belongs to the project of the key that created it. ## Add a project You need the `root` or `admin` role. 1. Open the project menu at the top of the sidebar. 2. Select **New project**. 3. Enter the **Name** of the shop, 1 to 80 characters. 4. Click **Create project**. The dashboard opens the **Wallets** page of the new project. Then set up the project: 1. Open **Wallets** and add a wallet for each network you accept. See [Wallets per chain](/docs/wallets/). 2. Open **Settings → Checkout** and set the branding and the return URL. See [Checkout](/docs/checkout/#branding). 3. Open **API keys** and create a key for the shop. 4. Open **Webhooks** and add the shop's endpoint. 5. Open **Team** and add the people who work on this shop. ## Project settings **Settings** has four tabs. Global admins have the `project_admin` role in every project. | Tab | Settings | Who can save | Two-factor code | |---|---|---|---| | **General** | **Project name**, **Payment window (minutes)**, **Invoice window (minutes)**, **IPN signature header** | `project_admin` | No | | **General** | IPN secret: **Show secret** | `developer` | Yes | | **General** | IPN secret: **Rotate** | `project_admin` | Yes | | **Checkout** | Logo, theme, accent colour, message, support links, **Return URL**, **Allowed return hosts** | `project_admin` | No | | **Exchange** | Trocador API key, markup, KYC rating, allowed coins | `project_admin` | Yes | | **Domain** | **Host name** of the checkout domain | `root` or `admin` | Yes | General settings: | Setting | Effect | |---|---| | **Project name** | 1 to 80 characters. The store name on the checkout pages | | **Payment window (minutes)** | Time to pay once a coin is chosen. Default 60, from 5 to 10080. Then an unpaid payment becomes `expired` | | **Invoice window (minutes)** | How long an invoice link stays open. Default 1440, from 5 to 43200 | | **IPN signature header** | Name of the HMAC-SHA512 signature header. Default `x-turmite-sig`. Up to 64 letters, digits and hyphens. Other names that start with `x-turmite-` are refused. See [Webhooks](/docs/webhooks/) | The Checkout tab is described in [Checkout](/docs/checkout/#branding). The Exchange tab is described in [Pay with any coin](/docs/exchange/#turn-it-on). ## How the addresses fit together | Address | Example | Serves | |---|---|---| | Site URL (`TURMITE_SITE_URL`) | `pay.example.com` | Checkout, merchant API and the dashboard | | Checkout domain (one per project, optional) | `pay.shop-two.com` | `/pay/`, `/i/`, `/x/`, `/v1/`, `/assets/`, `/branding/`, `/healthz` and `/favicon.svg` only | | Private entrance (optional) | `turmite.example-tailnet.ts.net` | The dashboard, for your own devices. See [Private dashboard](/docs/private-dashboard/) | On a checkout domain, `/dashboard` and every other path answer `404`. The checkout pages of other projects also answer `404`. A customer cannot reach a login form on a checkout domain. An example with two shops on one server: | Host name | Points to | Used for | |---|---|---| | `pay.example.com` | The server | Site URL. Checkout and API for the first project | | `pay.shop-two.com` | The server | Checkout domain of the second project | ## Give a project a checkout domain You need the `root` or `admin` role. These steps use the Caddy server from the install. With your own proxy, do steps 3 and 4 in your proxy. 1. Create DNS records for the name. Add an `A` record with the server's IPv4 address. Add an `AAAA` record if the server has IPv6. 2. Check the record from your computer: ```bash dig +short pay.shop-two.com ``` 3. Add the name to the Caddy site address. In `/opt/turmite/.env`, add it to `TURMITE_CADDY_ADDRESS`, separated by a comma: ```dotenv TURMITE_CADDY_ADDRESS=pay.example.com, pay.shop-two.com ``` 4. Start Caddy again with the new value: ```bash cd /opt/turmite docker compose up -d caddy ``` 5. Check that the server answers on the new name. The command must print `ok`: ```bash curl -s https://pay.shop-two.com/healthz ``` 6. In the dashboard, select the project and open **Settings → Domain**. 7. Enter `pay.shop-two.com` in **Host name**, without `https://`. 8. Enter a fresh code in **Two-factor code** and click **Save**. Then check the result: - `https://pay.shop-two.com/dashboard/login` answers `404`. - The `payment_url` and `invoice_url` of the project's payments start with `https://pay.shop-two.com/`. This is true for earlier payments too. - Links that you sent before the change still work on the site URL. Last, set the shop's API base URL to `https://pay.shop-two.com`. The merchant API also works on the checkout domain. When the site URL has a path, links on the checkout domain use the same path. To remove the checkout domain, save an empty **Host name**. ### Rules - A checkout domain belongs to one project. - The host of the site URL cannot be a checkout domain. - Turmite matches the domain on the `Host` header. Your proxy must pass the original `Host`. If you use Cloudflare, see [Reverse proxy, Cloudflare and firewall](/docs/reverse-proxy/#cloudflare) for TLS settings and certificates. ## Remove a project Turmite cannot delete a project. To stop a project: 1. Wait for every open payment to reach a final status. 2. In **Wallets**, click **Stop taking payments on** each network. 3. Revoke the project's API keys. 4. Remove its webhook endpoints and its team members. 5. Remove its checkout domain from **Settings → Domain**, from Caddy and from DNS. # Networks and nodes > Add RPC nodes for each network, set confirmations and tokens, and keep a second node for failover. Source: https://turmite.com/docs/nodes/ Turmite reads each chain through RPC nodes. You add the nodes in the dashboard. Turmite stores the node URLs in the database, sealed with `TURMITE_SECRET`. The networks, tickers and contracts are in [Tickers and networks](/docs/networks/). ## The Networks page Open **Server → Networks**. You need the `root` or `admin` role. Each network has a card with: - the enabled tickers, the highest block that a node reported, and its age; - a badge: **Connected**, **Not connected**, **No node** or **Off**, and **No new block** when the last block is old; - a table of nodes with the columns **Node**, **URL**, **Status**, **Height** and **Checked**. **In use** marks the node that the worker reads. The status is **OK**, **Error**, **Unknown** or **Off**, with the number of blocks the node is behind and its last error; - the **Add node** form; - the **Confirmations** field and the **Network on** check box; - **Assets**: the tickers of the network, and a form to add a token. **No new block** shows after 60 minutes without a new block on Bitcoin, 20 minutes on Monero and 5 minutes on the other networks. ## Node types | Family | Node | Example URL | |---|---|---| | Bitcoin | Bitcoin Core (`bitcoind`) JSON-RPC with wallet support. Put the RPC user and password in the URL | `http://user:password@bitcoind:8332` | | Monero | `monero-wallet-rpc`, started with `--wallet-dir` and `--disable-rpc-login` | `http://monero-wallet-rpc:18083` | | EVM | Ethereum JSON-RPC | `https://eth-mainnet.g.alchemy.com/v2/KEY` | | Tron | Tron full node HTTP API | `https://api.trongrid.io` | | Solana | Solana JSON-RPC | `https://api.mainnet-beta.solana.com` | | TON | toncenter HTTP API v2 | `https://toncenter.com/api/v2` | The URL must start with `http://` or `https://`. API keys: - A key in the URL path works for every family. - Tron and TON: put the key in an `apikey` or `api_key` query parameter. Turmite removes it from the URL and sends it as a header: `TRON-PRO-API-KEY` on Tron, `X-API-Key` on TON. - Bitcoin: Turmite sends the user and password of the URL as HTTP basic authentication. Bitcoin: Turmite makes one watch-only wallet per project on bitcoind, named `turmite-p`. Use one bitcoind per Turmite install, because two installs use the same wallet names. A bitcoind that you add later watches each project's addresses from the moment Turmite gives it the key. It does not find deposits that arrived before that moment. Monero: Turmite makes one view-only wallet file per project in the wallet directory. A wallet RPC has one open wallet at a time, and Turmite opens each project's file in turn. Give Turmite its own wallet RPC. The wallet files have no password, so keep the wallet RPC on a private network. Tron: a node with the `/v1/accounts` history API, such as TronGrid, lists each transfer. A plain full node gives only balances. Then Turmite does not see a top-up that arrives after a sweep. ## Add a node 1. Open **Server → Networks**. 2. In the card of the network, enter the URL in **Add node**. 3. Optional: enter a **Label**, up to 40 characters. Without a label, Turmite uses the host name. 4. Enter a fresh code in **Two-factor code**. 5. Click **Add**. Turmite saves the node, then connects to the network again. The message tells whether the network is connected. When the connection fails, the node stays in the list with its error. To delete a node, enter a two-factor code next to it and click **Remove**. Turmite checks the node when it connects: | Family | Check | |---|---| | EVM | The node's chain ID must be the network's chain ID | | Solana | The genesis hash must be mainnet-beta on a mainnet install and devnet on a testnet install. On a testnet install, a local URL is also accepted | | Bitcoin | When you save an account key, the key type must match the node's chain | | Monero | When Turmite makes a wallet, the wallet RPC must accept the address for its network | | Tron | The genesis block (block 0) must be mainnet on a mainnet install. A testnet install accepts Nile, Shasta and a local chain, but not mainnet | | TON | The masterchain zerostate must be mainnet on a mainnet install. A testnet install accepts the public testnet and a local chain, but not mainnet | Node URLs often contain API keys. The dashboard and the audit log show each URL without the user name, the password and the query. A path longer than 12 characters shows as `/redacted`. ## Failover Add at least two nodes for each network on mainnet. Use two providers, or your own node and one provider. - The order of the nodes is the order in which you added them. Turmite uses the first node that connects. - The worker checks every node every 30 seconds. The `api` process checks every 120 seconds. - A node fails a check when it does not answer in 15 seconds, or when it is too many blocks behind the best node of its network. - When the node in use fails two checks in a row, Turmite moves the network to the next healthy node. - When a node earlier in the order passes two checks in a row, Turmite moves the network back to it. - Each move writes a `node.failover` entry in the audit log. - Turmite connects again when you add or remove a node, or save a network. | Network | A node fails when it is more blocks behind than | |---|---| | Bitcoin | 2 | | Monero | 3 | | Ethereum | 10 | | Base, Polygon | 60 | | BNB Smart Chain | 160 | | Solana | 300 | | Tron, TON | 40 | A Monero wallet RPC gives a height only while a project's wallet is open. Without a height, a connected wallet RPC passes the check. Public endpoints have rate limits. Use them for tests only. ## Confirmations Each network has a confirmation count. A deposit stays in `confirming` until it has that many confirmations. Then the payment becomes `confirmed`, and `finished` when the deposits cover the amount due. The defaults are in [Tickers and networks](/docs/networks/#confirmations). 1. In the card of the network, change **Confirmations**. The count is 0 to 10000. 2. To lower the count, enter a fresh code in **Two-factor code**. Raising the count needs no code. 3. Click **Save**. The same form turns the network on or off with **Network on**. - Keep the default counts, or raise them. For large amounts, a higher count gives more protection against chain reorganisations. - A count of 0 credits a deposit that has no confirmation. Do not use 0 on mainnet. - On TON, a deposit counts as confirmed when the node lists it. The count has no effect. The checkout shows the progress as `1 of 3`, `2 of 3`. Up to 12 confirmations, it also shows one segment per confirmation. ## Tokens The contract addresses come from the catalog that is built into Turmite (`crates/turmite/src/chains/catalog.rs`). Turmite uses the correct contracts for the install's mode. Open **Assets** in the card of a network to see its tickers with their contracts and decimals. - To turn a ticker off or on, change its **On** check box. Enter a fresh code in **Two-factor code** and click **Save assets**. The native coin is in the list too. - To add a token on an EVM, Tron or Solana network, fill in **Asset**, **Ticker**, **Decimals** and **Contract**. Enter a fresh code and click **Add token**. The ticker has 2 to 20 lower-case letters and digits, and must be new. Decimals are 0 to 30. Each asset can have one contract per network. Copy the contract address from the token issuer. A wrong address loses payments. ## Trust in nodes A node cannot move funds. Turmite signs each sweep and payout itself, so a node cannot change where funds go. A node can give wrong data: - A node that hides transactions delays payments. A second node and the block heights on the Networks page show this. - A node that reports a false deposit can mark an unpaid order as paid. Use nodes that you run or pay for. For large orders, check the deposit on a block explorer before you ship. ## When a network falls behind If the Networks page shows **No new block**, or a node shows that it is behind: 1. Look at the node status and errors on the Networks page. 2. Read the worker log for errors about the network: ```bash cd /opt/turmite docker compose logs --since 1h worker | grep -i error ``` 3. Add a second node, or replace the node that fails. The worker checks each open payment every 5 seconds. When a node answers again, the next check finds the deposits. Turmite watches a payment until one day after its payment window ends. A deposit that arrives later does not count. See [Troubleshooting](/docs/troubleshooting/#payments). # Wallets per chain > Connect a wallet for each network, and check the first deposit address before you take payments. Source: https://turmite.com/docs/wallets/ Each project has one wallet setup per network. Open **Wallets** in the project to set them. You need the `project_admin` role or a global admin role. Each save needs a fresh two-factor code and writes an audit log entry. The Wallets page shows only the networks that are on. A card shows **No node** when its network has no connected node. The checkout offers a coin when its network and its token are on and the project has a wallet for the network. Connect a node for the network too. Without a node, the customer gets an error when they select the coin. To stop taking payments on one network, click **Stop taking payments on `Network`** in its card. Click **Take payments on `Network` again** to start again. This switch needs no two-factor code. ## Custody per chain | Network | You give Turmite | Deposit address | Who can move the funds | |---|---|---|---| | Bitcoin | Account public key (`xpub` or `zpub`) | Receive address of your account | Only you. Turmite has no Bitcoin key and does not sweep | | Monero | Primary address, private view key, restore height | Subaddress of your wallet | Only you. The view key cannot spend. Turmite does not sweep | | Ethereum, Base, Polygon, BNB Smart Chain | One cold wallet address | Deposit contract per payment (CREATE2) | The contract can only send to your cold wallet | | Tron, Solana, TON | Your address (TON: optional memo) | Key per payment from the install's deposit seed | The worker, until it sweeps the funds to your address | Use a hardware wallet or a multisig as the cold wallet. Turmite never needs its private key. ## Bitcoin Turmite watches the receive chain of a native SegWit (BIP84) account. Each payment gets the next receive address of that account. The coins go straight to your wallet. 1. In your wallet, export the account public key of a BIP84 account. On mainnet it starts with `zpub` or `xpub`. On Signet and the other test networks it starts with `vpub` or `tpub`. 2. In Turmite, open **Wallets**. In the Bitcoin card, paste the key in **Account key**. 3. Enter a fresh code in **Two-factor code** and click **Save**. 4. Compare **Address 0**, **Address 1** and **Address 2** with the first three receive addresses in your wallet (`m/84'/0'/0'/0/0` to `/2` on mainnet). They must be the same. If the addresses are different, do not take payments with this key. Turmite converts `zpub` to `xpub` and `vpub` to `tpub`. An `xpub` can also come from a legacy (BIP44) account. Turmite then shows native SegWit addresses that your wallet does not have. Turmite refuses these keys: - private keys (`xprv`, `zprv`, `tprv`, `vprv` and the other private formats); - wrapped SegWit keys (`ypub`, `upub`); - multisig keys (`Ypub`, `Zpub`, `Upub`, `Vpub`); - a key for another network than the install's mode. With a connected node, Turmite also checks the key against the node's chain. It makes a watch-only wallet `turmite-p` on bitcoind and shows the three addresses. Without a node, Turmite saves the key and shows no addresses. Connect a node, then save the key again. Each payment uses a new address. Turmite never uses an address twice, also after you change the key. Expired payments leave unused addresses. Many wallets stop at 20 unused addresses in a row. Use a wallet only for this shop, and set its gap limit above the number of open payments. A deposit can leave the mempool without a block, for example when the sender replaces it. Turmite then stops counting it toward the payment. The payment page in the dashboard shows **Dropped** for it. If the deposit comes back, it counts again. ## Monero Turmite makes a view-only wallet from your primary address and private view key. Each payment gets a new subaddress of account 0 of that wallet. The coins go straight to your wallet. | Field | Value | |---|---| | **Primary address** | The main address of the wallet, 95 characters. It starts with `4` on mainnet and `5` on Stagenet. Integrated addresses and subaddresses are refused | | **Private view key** | 64 hexadecimal characters, from the keys page of your wallet. Never enter the spend key | | **Restore height** | Optional. Leave it empty to start at the current block | 1. Open **Wallets** and fill in the Monero card. 2. Enter a fresh code in **Two-factor code** and click **Save**. Turmite checks that the view key belongs to the address before it sends either to the node. Payments go to new subaddresses, so blocks before the setup are not necessary. When the wallet RPC cannot give the current height, enter the restore height. A low height makes the first scan slow. Turmite seals the view key with `TURMITE_SECRET` and never shows it again. Each save needs the view key again. The Monero node is a `monero-wallet-rpc`. Turmite makes the wallet file `turmite-p-` in its wallet directory. The hash comes from the address and the restore height, so a new address or height gives a new file. The files have no password. See [Networks and nodes](/docs/nodes/#node-types). A Monero payment needs 10 confirmations, because Monero locks received outputs for 10 blocks. ## Ethereum, Base, Polygon and BNB Smart Chain One cold wallet serves the four EVM networks. Turmite computes a factory contract address from it. The factory has the same address on each EVM network. Each payment gets a deposit address that the factory computes with CREATE2. The deposit contract can only send funds to the cold wallet in the factory. A full compromise of the server cannot change this. 1. Open **Wallets**. In the **EVM networks** card, enter **Cold wallet address**. 2. Enter a fresh code in **Two-factor code** and click **Save**. The card shows the factory address. 3. Assign an EVM hot wallet to the project. See [Hot wallets](#hot-wallets). 4. On each network that shows **Not deployed**, enter a fresh code and click **Deploy now**. The worker sends the deployment from the project's EVM hot wallet within a minute. Turmite checks the address with the EIP-55 rules. An address in mixed case must have a correct checksum. All lower case and all upper case are accepted. Turmite stores the address in EIP-55 form. You can also skip **Deploy now**: - The worker deploys the factory before the first sweep on a network. - Anyone can deploy it. Open **Deploy the factory from your own wallet** and send the transaction (**To** and **Data**) with no value. The result is always the same factory. A deposit address can receive funds before its contract exists. The first sweep deploys the contract. ### Sweeps The worker sweeps in batches. One `flush` transaction moves the funds of many payments of one project and one factory to the cold wallet. The EVM hot wallet pays the gas. It cannot send the funds anywhere else. A batch goes out when one of these is true: - the gas is at most 2 % of the batch value; - the batch is worth 1000 USD or more; - the batch has 150 payments; - the oldest payment in the batch waited 6 hours. A batch never goes out while the gas is more than 20 % of its value. The funds then wait at the deposit addresses. Without USD prices, only a batch of native coins can be checked. Other batches wait. Turmite uses EIP-1559 fees: twice the base fee plus the node's priority fee, up to 1000 gwei. Above that cap, sends wait. When the hot wallet has too little native coin for the gas, the batch waits. When a token refuses the transfer, Turmite tries that payment again after one hour. The card lists the recent factory deployments and sweeps, with their status and fee. It shows **No hot wallet** when the project has no EVM hot wallet. If your cold wallet is a smart contract wallet, such as a multisig, it must exist at the same address on each network that you use. A multisig on Ethereum does not exist on Base until you deploy it there. Funds that go to an address with no wallet behind it are lost. To change the cold wallet, save the new address. It has a new factory. Payments made before the change keep their factory: their funds still go to the old cold wallet. ## Tron, Solana and TON On these chains, a deposit address cannot be locked to a destination. Turmite derives a key for each payment from the install's deposit seed. The worker sweeps the funds to your address after the deposit has the required confirmations. Until the sweep, the worker can move those funds. 1. Open **Wallets** and find the Tron, Solana or TON card. 2. Enter your **Address**. On TON, enter a **Memo** if the destination needs one. Turmite sends it as the text comment of each sweep. 3. Enter a fresh code in **Two-factor code** and click **Save**. Turmite checks the address before it saves it: | Network | With a connected node | Without a node | |---|---|---| | Tron | A valid `T` address | A `T` address with a correct checksum | | Solana | A public key that is not a token account or a token mint. Enter the wallet address that owns your token accounts | A base58 public key of 32 bytes | | TON | A valid raw or user-friendly address | 48 characters, or a raw address with `:` | Turmite creates the deposit seed on the first start. It stores the seed in the database, encrypted with age to `TURMITE_HOT_RECIPIENT`. Only the worker can decrypt it, with the hot-wallet identity and the passphrase. Back up the database and `secrets/` together. Without both, the keys of unswept deposits are lost. The `api` process does not have the deposit seed. The worker keeps 25 free deposit addresses for each of these networks and adds more every 30 seconds. When no free address is left, the merchant API answers `503` with `Retry-After: 30`. Sweep fees per asset: | Asset | Who pays the fee | |---|---| | TRX | The deposit. Turmite keeps back the bandwidth fee, or the activation fee when your address is new on chain | | USDT on Tron | The Tron hot wallet. It sends the deposit the TRX for energy and bandwidth, 5 % above the estimate. Then the deposit sends the tokens | | SOL | The deposit. It pays the fee and keeps nothing | | USDC and USDT on Solana | The Solana hot wallet. It pays the fee and the rent of your token account if that account does not exist. Turmite closes the empty deposit token account, and its rent goes back to the hot wallet | | TON | The deposit. It sends its whole balance and the fees come out of it. The first sweep also deploys the deposit wallet (v4r2). Balances below 0.01 TON are not swept | TON deposit addresses are wallets that are not yet deployed. Turmite shows them in the non-bounceable form. ## Hot wallets A hot wallet pays network fees for sweeps and sends payouts. Global admins manage them in **Server → Hot wallets**. Bitcoin and Monero have no hot wallet. | Family | Sweeps need the hot wallet for | |---|---| | EVM | Gas for the factory deployment and for each batch | | Tron | USDT sweeps | | Solana | Token sweeps | | TON | No sweeps. Payouts only | - **Make a hot wallet** generates a key. **Import a key** stores a key that you have. - **Assign to a project** gives a project one hot wallet per family. One hot wallet can serve many projects. - Each of these actions needs a fresh two-factor code. Keep a small balance in each hot wallet: about one week of fees and expected payouts. A server compromise can spend this balance. It cannot reach your cold wallets. See [Payouts](/docs/payouts/#how-hot-wallets-work). ## Test each wallet Before you go live, send a small real payment on each network. Check that: 1. the payment reaches `finished`; 2. the funds arrive in your wallet (EVM, Tron, Solana and TON: after the sweep); 3. your shop receives the webhook. See the [Go-live checklist](/docs/go-live/). # Run under a path > Serve Turmite under a path of an existing site, such as https://example.com/turmite, behind your own reverse proxy. Source: https://turmite.com/docs/path-prefix/ You can run Turmite beside another app on the same host. Put the path in the site URL. Your own reverse proxy keeps port 443 and the certificate, and passes the path to Turmite. ```dotenv TURMITE_SITE_URL=https://example.com/turmite ``` ## What follows the site URL Every URL that Turmite makes starts with the site URL: | What | URL | |---|---| | Dashboard | `https://example.com/turmite/dashboard` | | Invoice page | `https://example.com/turmite/i/{token}` | | Payment page | `https://example.com/turmite/pay/{token}` | | Merchant API | `https://example.com/turmite/v1/payment` | | Static files | `https://example.com/turmite/assets/` | | Health check | `https://example.com/turmite/healthz` | Your shop uses `https://example.com/turmite` as the API base URL. A project with a [checkout domain](/docs/projects/#give-a-project-a-checkout-domain) uses the same path on its own host, for example `https://pay.shop-two.com/turmite/pay/{token}`. ## Install with a path 1. Run the install script with the full URL and `--no-start`: ```bash curl -fsSL https://turmite.com/install.sh | sudo sh -s -- --site-url https://example.com/turmite --no-start ``` With a path in the site URL, `turmite init` sets `TURMITE_CADDY_ADDRESS=http://:80` in `.env`. The bundled Caddy would serve plain HTTP and get no certificate. 2. Your own proxy already uses ports 80 and 443. Turn off the bundled Caddy and publish the api port on the loopback interface. Write `/opt/turmite/docker-compose.override.yml`: ```yaml services: api: ports: - "127.0.0.1:8080:8080" caddy: profiles: [disabled] ``` 3. Start the stack: ```bash cd /opt/turmite && sudo docker compose up -d ``` Docker Compose reads the override file automatically. `install.sh --upgrade` does not change it. ## nginx Add this to the `server` block that serves `example.com`: ```nginx location = /turmite { return 301 /turmite/; } location /turmite/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; client_max_body_size 4m; } ``` Write `proxy_pass` without a path. nginx then passes `/turmite/...` unchanged, and Turmite removes the prefix itself. Turmite also answers requests without the prefix, but the links that it makes always contain the prefix. ## Caddy ```text example.com { handle /turmite/* { reverse_proxy 127.0.0.1:8080 } # your other app } ``` Use `handle`. Do not use `handle_path`, because it removes the prefix. ## Trusted proxy The api sees a proxy on the same host at the address of the Docker network gateway. That address is in the default `TURMITE_TRUSTED_PROXIES` list. If your proxy runs on another machine, set the list. See [Client IP addresses](/docs/reverse-proxy/#client-ip-addresses). ## Cookies and the origin check - The session cookie `turmite_session` has `Path=/turmite`. Your main app does not receive it. - Each dashboard `POST` must carry an `Origin` (or `Referer`) header with the origin of the site URL, here `https://example.com`. Do not let your proxy change `Origin`. ## Security of a shared host A path does not separate two apps in the browser. Your main app and Turmite have the same origin. A cross-site scripting bug in the main app lets a script act in the dashboard while an admin is signed in. The cookie path does not stop this. Turmite still asks for a fresh TOTP code for payouts, API keys and other sensitive actions. Use a path only beside an app that you trust as much as Turmite. In other cases, use a subdomain such as `pay.example.com`. ## Put the dashboard behind your own login Customers, your shop and the exchange need only these paths: | Path | Used by | |---|---| | `/turmite/i/` | Invoice pages | | `/turmite/pay/` | Payment pages and the status frame | | `/turmite/x/` | Pay with any coin: exchange pages, status frame and the exchange webhook | | `/turmite/v1/` | Your shop's server | | `/turmite/assets/`, `/turmite/favicon.svg` | Checkout styles and images | | `/turmite/branding/` | Project logos | | `/turmite/healthz` | Uptime checks | Protect everything else under the prefix. For example, with nginx: ```nginx location /turmite/ { auth_request /your-admin-auth; proxy_pass http://127.0.0.1:8080; # the same proxy_set_header lines as above } location ~ ^/turmite/(i/|pay/|x/|v1/|assets/|branding/|healthz$|favicon\.svg$) { proxy_pass http://127.0.0.1:8080; # the same proxy_set_header lines as above } ``` nginx uses the regular expression location for the public paths, because it takes priority over the prefix location. Turmite's own login and two-factor checks still apply. For other ways to protect the dashboard, see [Private dashboard](/docs/private-dashboard/). # Reverse proxy, Cloudflare and firewall > Put Turmite behind Caddy, nginx or Cloudflare, keep the real client IP, and close every other port. Source: https://turmite.com/docs/reverse-proxy/ The `api` process speaks plain HTTP on port 8080. A reverse proxy in front of it handles TLS. The install script sets up Caddy for this. The `worker` opens no port. ## Caddy (default) The install script starts Caddy with automatic TLS. Caddy gets a certificate for the host of the site URL and renews it. Ports 80 and 443 must be open to the internet for this. The site address comes from `TURMITE_CADDY_ADDRESS` in `.env`. `turmite init` sets it to the host of the site URL. The main parts of `/opt/turmite/Caddyfile`: ```text { servers { trusted_proxies static {$TURMITE_TRUSTED_PROXIES:private_ranges} } } (turmite) { encode zstd gzip request_body { max_size 4MB } header ?Strict-Transport-Security "max-age=31536000" header ?X-Content-Type-Options "nosniff" header ?Referrer-Policy "same-origin" header ?Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" header ?Cross-Origin-Opener-Policy "same-origin" header -Server header -Via reverse_proxy api:8080 { header_up -Forwarded } } {$TURMITE_CADDY_ADDRESS} { import turmite } ``` Turmite sets its own content security policy, frame, cache and referrer headers per page. On an `https://` site URL it also sends `Strict-Transport-Security: max-age=31536000`. The `?` headers in Caddy apply only when Turmite did not set the header. After you change the Caddyfile, restart Caddy: ```bash cd /opt/turmite sudo docker compose restart caddy ``` `install.sh --upgrade` keeps your Caddyfile. If a release changes it, the script writes `Caddyfile.new` next to it. For a project's checkout domain, see [Give a project a checkout domain](/docs/projects/#give-a-project-a-checkout-domain). ## Your own proxy You can use another proxy, such as nginx. The compose file does not publish the api port. Publish it on the loopback interface, and turn off the bundled Caddy, in `/opt/turmite/docker-compose.override.yml`: ```yaml services: api: ports: - "127.0.0.1:8080:8080" caddy: profiles: [disabled] ``` Docker Compose reads this file automatically. `install.sh --upgrade` does not change it. On a new install, run the install script with `--no-start`, write the file, then run `sudo docker compose up -d`. Point the proxy at the api, and pass the `Host` header and the client IP: ```nginx server { listen 443 ssl; server_name pay.example.com; # ssl_certificate and ssl_certificate_key lines here location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; client_max_body_size 4m; } } ``` Never publish port 8080 on a public interface. Rules for every proxy: - Pass the original `Host` header. Turmite uses it to find [checkout domains](/docs/projects/#give-a-project-a-checkout-domain). - Do not change the `Origin` header. The dashboard refuses a `POST` whose `Origin` does not match the site URL. - Allow request bodies of at least 4 MB. A logo upload can be 2 MB, plus the form data. To serve Turmite under a path of another site, see [Run under a path](/docs/path-prefix/). ## Client IP addresses Turmite uses the client IP for rate limits and the audit log. The limits are 10 sign-in attempts a minute per IP address and per email address, 600 merchant API requests a minute per IP address, and 300 checkout requests a minute per IP address. Turmite reads `X-Forwarded-For` only when the connection comes from an address in `TURMITE_TRUSTED_PROXIES`. It then uses the rightmost address in the header that is not a trusted proxy. The default list is loopback and the private ranges, which include the Docker network that Caddy uses. The bundled Caddy reads the same variable for its own list of trusted proxies. If a proxy connects from a public address, set the list in `.env`. The list replaces the default, so add the private ranges again. Separate the ranges with spaces, because Caddy does not accept commas: ```dotenv TURMITE_TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 203.0.113.7/32 ``` Then run `sudo docker compose up -d` in `/opt/turmite`. If the setting is wrong, all visitors look like one IP address. Then one person can lock everyone out of sign-in for a minute, and busy checkouts hit the limit. ## Cloudflare Cloudflare's proxy can sit in front of the server. Visitors then see Cloudflare addresses, and you can block paths before they reach the server. 1. Add the domain to Cloudflare. 2. In **DNS → Records**, add the `A` record (and `AAAA` if you have IPv6) with **Proxy status: Proxied**. 3. In **SSL/TLS → Overview**, set the mode to **Full (strict)**. Do not use **Flexible**. It sends traffic to your server without encryption and causes redirect loops. 4. Trust Cloudflare's addresses. See the next section. 5. Restrict ports 80 and 443 to Cloudflare's addresses. See [Firewall](#firewall). ### Keep the real visitor IP Behind Cloudflare, every request comes from a Cloudflare address. Cloudflare adds the visitor's address to `X-Forwarded-For`. Add Cloudflare's ranges to `TURMITE_TRUSTED_PROXIES`. Caddy then keeps the header, and Turmite takes the visitor's address from it. ```bash cf=$({ curl -fsS https://www.cloudflare.com/ips-v4; echo; curl -fsS https://www.cloudflare.com/ips-v6; } | xargs) echo "TURMITE_TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 $cf" | sudo tee -a /opt/turmite/.env cd /opt/turmite && sudo docker compose up -d ``` Keep only one `TURMITE_TRUSTED_PROXIES` line in `.env`. Cloudflare changes its ranges rarely. Run the command again from time to time, and remove the old line. ### Certificates behind Cloudflare Caddy still gets its own certificate behind Cloudflare with **Full (strict)**. Cloudflare challenges and "Under Attack" mode can block the certificate check. The certificate then fails to renew, and visitors see error 525 or 526. A Cloudflare Origin Certificate avoids this. Only Cloudflare trusts it, which is enough behind the proxy. 1. In Cloudflare, open **SSL/TLS → Origin Server → Create Certificate**. 2. List the host names, for example `pay.example.com`. 3. Save the certificate as `/opt/turmite/origin.pem`. 4. Save the private key as `/opt/turmite/origin-key.pem`. Cloudflare shows the key one time only. 5. Run `sudo chmod 600 /opt/turmite/origin-key.pem`. 6. Add a `tls` line to the site block of the Caddyfile: ```text {$TURMITE_CADDY_ADDRESS} { import turmite tls /etc/caddy/origin.pem /etc/caddy/origin-key.pem } ``` 7. Mount the files into the Caddy container, in `/opt/turmite/docker-compose.override.yml`: ```yaml services: caddy: volumes: - ./origin.pem:/etc/caddy/origin.pem:ro - ./origin-key.pem:/etc/caddy/origin-key.pem:ro ``` 8. Run `sudo docker compose up -d caddy`. Each Cloudflare zone needs its own certificate. A checkout domain in a different zone, such as `pay.shop-two.com`, needs a second certificate and its own site block. ### Bot protection and your shop Cloudflare Bot Fight Mode and a high Security Level can challenge requests that do not come from a browser. Your shop's server then gets `403` from `/v1/`. To fix this, add a WAF custom rule with the action **Skip** for paths that start with `/v1/`. Or turn off Bot Fight Mode for the zone. Webhooks leave your server directly. Cloudflare does not touch them. ## Firewall Open only these ports: | Port | From | Use | |---|---|---| | 22 | Your IP addresses | SSH | | 80, 443 | Anywhere, or only Cloudflare's ranges when you use Cloudflare | HTTP and HTTPS | Never publish PostgreSQL (5432) or the api port (8080). The compose file keeps PostgreSQL on an internal network with no route to the internet. ### Docker bypasses ufw Docker writes its own iptables rules for published ports. These rules come before the ufw rules. A ufw rule does not stop traffic to a port that Docker publishes. Use one of these: - The firewall of your hosting provider. It filters traffic before it reaches the server, so Docker cannot change it. This is the better choice. - The `DOCKER-USER` iptables chain. Docker reads this chain before its own rules. Example: let only Cloudflare reach ports 80 and 443. ```bash for r in $(curl -fsS https://www.cloudflare.com/ips-v4); do sudo iptables -I DOCKER-USER -i eth0 -s "$r" -p tcp -m multiport --dports 80,443 -j RETURN done sudo iptables -A DOCKER-USER -i eth0 -p tcp -m multiport --dports 80,443 -j DROP sudo apt install iptables-persistent sudo netfilter-persistent save ``` Replace `eth0` with your public interface. `ip route get 1.1.1.1` shows it. Do the same with `ip6tables` for IPv6. Caddy also listens on UDP 443 for HTTP/3. Add the same rules for `-p udp` to filter it too. ### Check the firewall From another machine, connect to the server IP directly. Behind Cloudflare, this command must time out: ```bash curl -sk --max-time 10 --resolve pay.example.com:443:198.51.100.10 https://pay.example.com/healthz ``` Replace `198.51.100.10` with the server's public IP address. ## Next To keep the dashboard off the public internet, see [Private dashboard](/docs/private-dashboard/). # Private dashboard > Keep the checkout and the merchant API public, and make the dashboard reachable only for your team. Source: https://turmite.com/docs/private-dashboard/ Customers need the checkout. Your shop needs the merchant API. Only your team needs the dashboard, and the dashboard is where money moves. Block the dashboard on every public host name. Then reach it through a private entrance. Two-factor codes still apply. These steps reduce who can try a password at all. ## Public and private paths | Path | Who needs it | |---|---| | `/i/`, `/pay/` | Customers (checkout) | | `/x/` | Customers and the exchange (pay with any coin) | | `/v1/` | Your shop's server (merchant API) | | `/assets/`, `/branding/`, `/favicon.svg` | Customers (checkout styles, images and project logos) | | `/healthz` | Uptime monitors | | `/dashboard` and everything under it | Only your team | `/` redirects to `/dashboard`. With a path in the site URL, add the prefix. For example, `/turmite/dashboard`. A [checkout domain](/docs/projects/#give-a-project-a-checkout-domain) serves only the paths above, without `/dashboard`. It answers `404` for every other path. The steps below are for the host of the site URL. ## Option 1: Tailscale [Tailscale](https://tailscale.com) makes a private network between your devices and the server. The dashboard gets an address that only your devices can open. 1. Install Tailscale on the server and sign in: ```bash curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up ``` 2. Install Tailscale on your laptop and phone. Sign in with the same account. 3. In the Tailscale admin console, turn on MagicDNS and HTTPS certificates. 4. Run `tailscale status` on the server. Note its name, for example `turmite.example-tailnet.ts.net`. 5. Add a private entrance to the Caddyfile. Replace both host names with your own: ```text http://:8081 { reverse_proxy api:8080 { header_up Host pay.example.com header_up Origin ^https://turmite\.example-tailnet\.ts\.net$ https://pay.example.com } } ``` 6. Publish port 8081 on the loopback interface only, in `docker-compose.override.yml`: ```yaml services: caddy: ports: - "127.0.0.1:8081:8081" ``` 7. Start Caddy, then give the port to Tailscale: ```bash cd /opt/turmite sudo docker compose up -d caddy sudo tailscale serve --bg http://127.0.0.1:8081 ``` 8. Open `https://turmite.example-tailnet.ts.net/dashboard/login` from a device on your tailnet. Docker Compose reads `docker-compose.override.yml` automatically. `install.sh --upgrade` keeps it and your Caddyfile. Turmite accepts a dashboard `POST` only when its `Origin` matches the origin of the site URL. The entrance sends the site URL's host to Turmite. The `Origin` line changes only requests that come from your tailnet name. Do not replace it with a fixed value. A fixed value turns off the cross-site check for this entrance. If the `.ts.net` name does not resolve on your laptop, turn on Tailscale DNS there: `sudo tailscale set --accept-dns=true`. ### Alternative: a site URL on the tailnet You can also set `TURMITE_SITE_URL` to the tailnet name and give every project a checkout domain. Payment and invoice links then use the checkout domain of the project. A checkout domain serves the checkout and the merchant API, and never the dashboard. Webhooks leave the server directly, so they still work. This setup needs more changes: - Caddy cannot get a public certificate for a `.ts.net` name. Serve the site URL through `tailscale serve`, and give Caddy a site block for each checkout domain. - Turmite gives the exchange a webhook URL on the site URL, which the exchange cannot reach. The worker also polls each swap every 10 seconds, so swaps still complete. ## Option 2: Cloudflare Access Cloudflare Access (Zero Trust) asks for a team sign-in before a request reaches the server. 1. In Cloudflare Zero Trust, open **Access → Applications**. 2. Add a self-hosted application. 3. Set the domain to `pay.example.com` and the path to `dashboard`. 4. Add a policy that allows only your team's email addresses. 5. Save the application. The checkout and the merchant API stay open, because the application covers only `/dashboard`. ## Option 3: Cloudflare WAF rule A WAF custom rule blocks the dashboard on the public host names. 1. In Cloudflare, open the domain, then **Security → WAF → Custom rules**. 2. Select **Create rule**, then **Edit expression**. 3. Paste this expression: ```text (http.host in {"pay.example.com"} and starts_with(http.request.uri.path, "/dashboard")) ``` 4. Set the action to **Block**. 5. Deploy the rule. To keep the dashboard open from your office, add your addresses before the last bracket: ```text (http.host in {"pay.example.com"} and starts_with(http.request.uri.path, "/dashboard") and not ip.src in {203.0.113.10}) ``` Add each public host name to the `{...}` list, separated by spaces. A host in another Cloudflare zone needs the same rule in that zone. ## Option 4: block on the server A Cloudflare rule protects only traffic that goes through Cloudflare. To make the server refuse the dashboard on public names, add two lines to each public site block in the Caddyfile: ```text {$TURMITE_CADDY_ADDRESS} { import turmite @dashboard path /dashboard /dashboard/* respond @dashboard 404 } ``` Then run `sudo docker compose restart caddy`. Do not add these lines to the private entrance on port 8081. If you do, you lock yourself out. ## Recommended setup Use Tailscale to sign in. Use a WAF rule or the Caddy block on every public host name. Use the Caddy block when you do not use Cloudflare. ## Check the result From a browser that is not on your tailnet: 1. `https://pay.example.com/dashboard/login` is blocked or returns `404`. 2. `https://pay.example.com/healthz` returns `ok`. 3. A payment link from your shop opens the checkout. From a device on your tailnet: 1. The dashboard opens on the private entrance. 2. You can sign in and save a setting. # Backups and restore > Back up the database, the .env file and the secrets folder, keep a copy off the server, and test a restore. Source: https://turmite.com/docs/backups/ A Turmite install has three items to back up. Your cold wallet seeds are a fourth item, which you keep yourself. ## What to back up | Item | Location | If you lose it | |---|---|---| | Database | Docker volume `pgdata` of the `postgres` service | Payment history, settings, users, encrypted hot keys and the encrypted deposit seed. Funds on chain stay where they are | | `.env` | `/opt/turmite/.env` | `TURMITE_SECRET`. TOTP secrets, IPN and webhook secrets, node URLs, Monero view keys and exchange API keys become unreadable | | `secrets/` | `/opt/turmite/secrets/` (`hot-identity.age`, `hot_passphrase`) | The hot wallet identity. The worker cannot decrypt hot keys or the deposit seed, and it does not start | | Hot wallet passphrase | `secrets/hot_passphrase`, or only with you | The same as losing `secrets/` | | Cold wallet seeds | Offline, with you | Everything in your cold wallets. Turmite cannot help | Tron, Solana and TON deposits stay at their deposit addresses until the worker sweeps them. The keys for these addresses come from the deposit seed. Turmite creates the seed on the first start and stores it in the database, encrypted with age to `TURMITE_HOT_RECIPIENT`. To recover unswept funds after a loss, you need the database, `secrets/` and the passphrase from the same install. ## Daily database dump The install script puts `backup.sh` in `/opt/turmite`. It dumps the database to `backups/turmite-.sql.gz`, prints the file name, and deletes dumps that are older than 14 days. 1. Test it: ```bash sudo /opt/turmite/backup.sh ``` 2. Open the crontab of root with `sudo crontab -e`. 3. Add this line. It runs the dump at 03:00 each night: ```text 0 3 * * * /opt/turmite/backup.sh ``` Options: `--keep-days N` keeps the dumps for `N` days. `--dir DIR` uses another install directory. The dumps also contain `DROP` statements, so `restore.sh` can load them into a database that has data. ## Copy the dumps off the server A backup on the same disk does not survive the loss of the server. Copy the dumps to another place each night. This example uses [restic](https://restic.net) with an S3-compatible bucket: ```bash sudo apt install restic export RESTIC_REPOSITORY=s3:https://s3.example.com/turmite-backups export RESTIC_PASSWORD_FILE=/root/.restic-pass restic init restic backup /opt/turmite/backups restic forget --keep-daily 14 --keep-monthly 6 --prune ``` Run the `restic backup` and `restic forget` lines from root's crontab after the dump. Keep the restic password outside the server too. The dump contains encrypted hot keys and customer data such as order IDs. Encrypt it at rest. restic does this for you. ## Back up .env and secrets Copy `/opt/turmite/.env` and `/opt/turmite/secrets/` to encrypted storage that is not on the server. Do this after the install and again after each change. ```bash sudo tar -czf turmite-config-$(date +%F).tar.gz -C /opt/turmite .env secrets ``` Keep the hot wallet passphrase in a password manager, apart from `secrets/`. Provider snapshots help you restore the whole server quickly. They do not replace the off-site dumps. ## Restore Use the dump, `.env` and `secrets/` from the same install. `restore.sh` loads a dump in one transaction: if a statement fails, it rolls back the restore. It stops the `api` and the `worker` during the restore, and starts them again after it. On the same server: ```bash sudo /opt/turmite/restore.sh /opt/turmite/backups/turmite-20261001-030000.sql.gz ``` The script asks for confirmation. `--yes` skips the question. On a new server: 1. Install the files without starting the stack. This also installs Docker. The script makes a new `.env` and `secrets/`, which you replace in the next step: ```bash curl -fsSL https://turmite.com/install.sh | sudo sh -s -- --site-url https://pay.example.com --yes --no-start ``` 2. Put back `.env` and `secrets/` from your backup: ```bash sudo tar -xzf turmite-config-2026-10-01.tar.gz -C /opt/turmite ``` 3. Copy the dump to the server, then load it. PostgreSQL starts for the first time with the database password from your `.env`: ```bash sudo /opt/turmite/restore.sh --yes /root/turmite-20261001-030000.sql.gz ``` 4. Start the stack: `cd /opt/turmite && sudo docker compose up -d`. 5. Check the result: - `curl -s https://pay.example.com/healthz` prints `ok`; - you can sign in to the dashboard; - each network on **Server → Networks** shows a recent block; - the worker log shows no errors about the hot wallet identity. If DNS points to the old server, change the records to the new IP address. The install script pulls the latest image, but your `.env` keeps the old `TURMITE_VERSION`. The stack runs that version. To run the latest version, [upgrade](/docs/upgrades/) after the restore. ## After a restore Records from after the dump are not in the database. The worker watches only the payments that are in the database, so it does not see funds for payments that were made after the dump. These funds are still on chain: - Bitcoin and Monero funds are in your wallets. - EVM funds are in deposit contracts that can only send to your cold wallet. - Tron, Solana and TON funds wait at their deposit addresses. Compare recent orders in your shop with the chain and your wallets. Ask customers who paid in that time to contact you. The address counters also come back from the dump. New payments can then get deposit addresses that Turmite gave out after the dump, and two payments share one address. Before the stack takes payments, move the counters on with an amount larger than the number of payments made after the dump: ```bash cd /opt/turmite sudo docker compose stop api worker sudo docker compose run --rm api counters raise --by 1000 sudo docker compose start api worker ``` The command adds the amount to each counter and deletes the free pool addresses. On Bitcoin, the skipped addresses are a gap in your wallet: set the gap limit of your wallet above the amount. See [`turmite counters raise`](/docs/cli/#turmite-counters-raise). The same change in SQL, for example in `psql` (`sudo docker compose exec postgres psql -U turmite -d turmite`): ```sql BEGIN; UPDATE network_address_counter SET next_index = next_index + 1000; UPDATE family_address_counter SET next_index = next_index + 1000; UPDATE project_wallets SET next_index = next_index + 1000; DELETE FROM deposit_addresses WHERE project_id IS NULL AND payment_id IS NULL; COMMIT; ``` Monero subaddresses do not need this step while you keep the wallet files on the wallet RPC host: the wallet RPC counts the subaddresses in each file. ## Test a restore Restore to a spare server at least once before you go live. Repeat the test after large upgrades. # Upgrades > Update Turmite to a new release, with a database dump before each upgrade. Source: https://turmite.com/docs/upgrades/ Turmite releases are on [GitHub](https://github.com/loruorg/turmite/releases). The image is `ghcr.io/loruorg/turmite`, tagged with each version. When update checks are on, admins see a banner in the dashboard when a new release exists. See [Update checks](#update-checks). ## Before you upgrade 1. Read the release notes in the [changelog](/docs/changelog/). Look for steps that the release needs. 2. If the passphrase is not stored in `secrets/`, be ready to type it after the upgrade. See [Run without a stored passphrase](/docs/install/#run-without-a-stored-passphrase). Payouts in `signing` or `initiated` need no action. The worker stores each signed transaction before it broadcasts it, and it continues after a restart. ## Upgrade with the install script Run this on the server: ```bash curl -fsSL https://turmite.com/install.sh | sudo sh -s -- --upgrade ``` Add `--version X.Y.Z` to choose the version. Without it, the script uses the latest release. The **Updates** page in the dashboard shows the same command. The script does these steps in `/opt/turmite`: 1. It pulls the new image. 2. It dumps the database to `backups/turmite--.sql.gz`. If the dump fails, it stops and changes nothing. 3. It copies `docker-compose.yml` to `backups/docker-compose-.yml`. 4. It writes the `docker-compose.yml`, `backup.sh` and `restore.sh` of the new version. 5. It keeps your `Caddyfile`. If the new version has a different one, it writes it as `Caddyfile.new`. Compare the two files and merge what you need. 6. It sets `TURMITE_VERSION` in `.env`. 7. It runs `docker compose up -d --remove-orphans`. Migrations run when the services start. 8. It waits up to 2 minutes until the `api` is healthy. If the `api` does not get healthy, it prints the last log lines of the `api` and the `worker`. 9. It watches the `worker` container for 15 seconds. If the worker stops or restarts, the script prints its last log lines and the commands to start it, and exits with an error. Without a stored passphrase, the new worker container has no passphrase and stops. Start it with the passphrase as the script shows. To read the worker log later: ```bash cd /opt/turmite sudo docker compose ps sudo docker compose logs --since 10m worker ``` `backup.sh` deletes dumps that are older than its `--keep-days` value, the upgrade dumps too. Copy an upgrade dump to another place if you want to keep it longer. ## Upgrade by hand 1. Take a dump: `sudo /opt/turmite/backup.sh`. 2. Set the new version in `/opt/turmite/.env`, for example `TURMITE_VERSION=0.2.0`. Docker Compose reads the image tag from this variable. 3. Pull the image and restart the services: ```bash cd /opt/turmite sudo docker compose pull sudo docker compose up -d ``` 4. Check the version: ```bash sudo docker compose exec api turmite version ``` This keeps your `docker-compose.yml`. If the release notes change the compose file, use the install script. ## Run migrations by hand Every `turmite serve` process runs the migrations at start, in each role. The migration step holds a PostgreSQL advisory lock, so two processes that start at the same time do not apply a migration twice. To run them as a separate step: ```bash cd /opt/turmite sudo docker compose run --rm api migrate ``` The command prints `migrations applied` and exits. ## Upgrade from source ```bash git pull cargo build --release ./target/release/turmite migrate ``` Then restart the `api` and `worker` processes with the new binary. ## Roll back Migrations have no down steps. An older version does not start on a database that a newer version migrated. The dump from before the upgrade is the only way back: 1. Set the old version in `.env`: `TURMITE_VERSION=`. 2. Put back the old compose file: ```bash cd /opt/turmite sudo cp backups/docker-compose-.yml docker-compose.yml ``` 3. Restore the dump from before the upgrade. The script stops the `api` and the `worker`, loads the dump and starts them again with the old version: ```bash sudo ./restore.sh backups/turmite--.sql.gz ``` 4. Start the rest of the stack: `sudo docker compose up -d`. Records from after the dump are lost. Compare recent orders with the chain, as described in [After a restore](/docs/backups/#after-a-restore). ## Update checks A minute after the worker starts, and then once a day, it reads the latest release and the published security advisories of Turmite from the GitHub API. The request sends `User-Agent: turmite/` and no other data. A worker that restarts does not ask again when the last check is less than an hour old. Root and admin users see the results: - A banner when a newer release exists. **Dismiss** hides it until the next release. - A red banner for each advisory that affects the running version. It stays until the server runs a fixed version. - The **Server → Updates** page: the running version, the latest release, the last check, the advisories and a **Check now** button. When SMTP is set up, Turmite emails each critical or high advisory that affects the running version to all root and admin users, one time per advisory. Development builds (`-dev` versions) get no advisories. Checks are on by default. To turn them off, clear **Check for updates every day** on the **Updates** page. This needs a two-factor code. With checks off, Turmite shows no banners. Then watch the [releases](https://github.com/loruorg/turmite/releases) and the [advisories](https://github.com/loruorg/turmite/security/advisories) on GitHub yourself. # Monitoring and logs > Check the health endpoint, read the JSON logs, and alert on the events that need a person. Source: https://turmite.com/docs/monitoring/ Monitor three things: the `api` answers, the `worker` follows each chain, and the hot wallets have funds. ## Health check `GET /healthz` returns `ok` with status `200` when the `api` process answers HTTP. It does not check the database or the nodes. A checkout domain also serves it. ```bash curl -s https://pay.example.com/healthz ``` Point an external uptime monitor at this URL. Check it every minute. Docker checks the containers too. `docker compose ps` shows the result: | Service | Health check | |---|---| | `postgres` | `pg_isready` every 5 seconds | | `api` | `turmite healthcheck` every 30 seconds, after a start period of 60 seconds. Unhealthy after 3 failures | | `worker` | None. It opens no port | | `caddy` | None | Docker does not restart a container that is unhealthy. It restarts a container that stops (`restart: unless-stopped`). Watch the `worker` through the logs and the **Server → Networks** page. ## Logs Turmite writes one JSON object per line to standard output when the output is not a terminal, as in Docker. Each line has `timestamp`, `level` (`TRACE`, `DEBUG`, `INFO`, `WARN` or `ERROR`), `target`, `message` and the fields of the event, for example `payment_id` or `network`. The default level is `info`. ```bash cd /opt/turmite sudo docker compose logs -f api worker sudo docker compose logs --since 1h worker ``` To show only errors, use `jq`: ```bash sudo docker compose logs --no-log-prefix --since 24h worker | jq -cR 'fromjson? | select(.level == "ERROR")' ``` Logs do not contain API keys, passwords, TOTP secrets or private keys. Node errors show the node URL without the user name, the password, the query and a long path, because providers often put the API key there. On testnet without SMTP, Turmite writes each email to the log, at level `WARN`, with the message `smtp not set up; email written to the log`. The compose file limits the logs of each service to 5 files of 10 MB. ## What to alert on | Signal | Where to see it | Action | |---|---|---| | `/healthz` does not return `ok` | Uptime monitor | Read the `api` log. Check PostgreSQL | | `"level":"ERROR"` in any log | Logs | Read the message. Most errors name the network or the payment | | **No new block** or **Not connected** on a network | Server → Networks | Check the nodes. Add or replace a node. See [Networks and nodes](/docs/nodes/#when-a-network-falls-behind) | | `network moved to another node` (`WARN`), `node.failover` in the audit log | Logs, Server → Audit log | A node failed. Check it | | Hot wallet balance is low | Server → Hot wallets | Send funds from your cold wallet. Without gas, sweeps and payouts stop | | **Funds wait at deposit addresses** | Project overview, payment page under **Sweeps to your wallet** | A sweep is not final after 30 minutes, or confirmed funds are not swept after 60 minutes. Read the reason, check the hot wallet balance and the node, and read the `worker` log | | Webhook deliveries in **Dead** | Webhooks → Deliveries | Fix the endpoint, then click **Retry** | | Payouts in `needs-review` | Payouts, and the `payout.needs-review` event | Check the transaction on a block explorer. See [Payouts](/docs/payouts/) | | Update or advisory banner | Dashboard | Plan an upgrade. See [Upgrades](/docs/upgrades/) | | Disk space | Server | Keep at least 20% free for PostgreSQL, the dumps and the logs | | Certificate expiry | Uptime monitor with TLS checks | Read the Caddy log: `sudo docker compose logs caddy` | The **No new block** badge shows when a network has no new block for 60 minutes on Bitcoin, 20 minutes on Monero and 5 minutes on the other networks. ## Node failover The `worker` probes every enabled node of each network every 30 seconds. The `api` probes them every 120 seconds. A probe reads the block height of the node, with a timeout of 15 seconds. All networks report a height. Monero reports none while its wallet scans, or until a project has a Monero wallet. The probe then only checks that the node answers. - A node that is far behind the best node of its network counts as failed: more than 2 blocks on Bitcoin, 3 on Monero, 10 on Ethereum, 60 on Base and Polygon, 160 on BNB Smart Chain, 300 on Solana and 40 on Tron and TON. - After 2 failed probes in a row, the network moves to the next healthy node, in the order that you added the nodes. - When a node of higher priority passes 2 probes in a row, the network moves back to it. The **Networks** page marks the node in use with **In use**. Each move writes a `node.failover` entry to the audit log. ## Events to your own tools A project webhook endpoint can feed your own alerting. Add an endpoint for your monitoring tool and react to these events: - `payout.needs-review`; - `payout.failed`. See [Webhooks](/docs/webhooks/) for the body and signatures. ## Audit log Read **Server → Audit log** each week. Look for sign-ins from unknown IP addresses, new API keys, wallet changes, new nodes and address book changes. ## Resource use ```bash sudo docker stats --no-stream sudo docker compose exec postgres psql -U turmite turmite -c "select pg_size_pretty(pg_database_size('turmite'));" ``` Run self-hosted chain nodes on other machines. They need much more CPU, memory and disk than Turmite. # Troubleshooting > Find and fix common problems with the install, sign-in, payments, sweeps, payouts and webhooks. Source: https://turmite.com/docs/troubleshooting/ Run each `docker compose` command as root in `/opt/turmite`. ## Read the logs first ```bash sudo docker compose ps sudo docker compose logs --tail 200 api sudo docker compose logs --since 1h worker | grep -i -E 'warn|error' sudo docker compose logs caddy ``` Each log line is a JSON object. Errors name the network, the payment or the delivery that failed. See [Monitoring and logs](/docs/monitoring/). ## Install and Docker ### Caddy cannot get a certificate - The DNS record must point to this server. Check with `dig +short pay.example.com`. - Ports 80 and 443 must be open to the internet. Check the provider firewall. - Behind Cloudflare, see [Certificates behind Cloudflare](/docs/reverse-proxy/#certificates-behind-cloudflare). ### Containers cannot resolve DNS This happens with Tailscale or a local DNS stub (`nameserver 100.100.100.100` or `127.0.0.53`). Give Docker other resolvers: ```bash echo '{ "dns": ["1.1.1.1", "9.9.9.9"] }' | sudo tee /etc/docker/daemon.json sudo systemctl restart docker ``` If `daemon.json` already exists, add the `dns` key to it. Do not overwrite the file. ### docker compose cannot read .env Only root can read `/opt/turmite/.env`. Run `docker compose`, `backup.sh` and `restore.sh` with `sudo`. Do not make `.env` readable for other users. It holds `TURMITE_SECRET` and the database password. ### The install script stops | Message | Fix | |---|---| | `/opt/turmite/.env exists. Use --upgrade, or remove /opt/turmite to start again.` | Upgrade with `--upgrade`, or remove the directory | | `pass --site-url https://pay.example.com` | Give `--site-url`. Without a terminal, or with `--yes`, the script cannot ask | | `set TURMITE_HOT_PASSPHRASE, run in a terminal, or pass --yes` | Run the script in a terminal, or pass `--yes` for a random stored passphrase | | `this script supports Ubuntu and Debian; install Docker Engine and the Compose plugin yourself` | Install Docker, then run the script again | | `the API did not become healthy; see the logs above` | Read the log lines above the message. See [The api exits at start](#the-api-exits-at-start) | ### The api exits at start Read the last lines of its log: `sudo docker compose logs --tail 50 api`. A start-up error is one line that starts with `error:`. | Message | Fix | |---|---| | `set TURMITE_SECRET (64 hex characters; ...)` | Set `TURMITE_SECRET`. `turmite init` writes one | | `TURMITE_SECRET must be hex` or `must be 64 hex characters (32 bytes)` | Use 64 hex characters, for example from `openssl rand -hex 32` | | `TURMITE_MODE must be mainnet or testnet` | Fix the value of `TURMITE_MODE` | | `this database was set up for testnet; TURMITE_MODE is mainnet` | Set `TURMITE_MODE` back to the mode of the database, or use a new database for the other mode | | `TURMITE_SITE_URL must be an http(s) URL` or `must not have a query or fragment` | Use a full `http://` or `https://` URL, without a query or a fragment | | `mainnet needs an https:// TURMITE_SITE_URL` | Use an `https://` site URL on mainnet | | `TURMITE_TRUSTED_PROXIES: bad range` | Use CIDR ranges or IP addresses, separated by spaces | | `TURMITE_SMTP_URL: ...` or `TURMITE_SMTP_FROM: ...` | Fix the SMTP value. Percent-encode special characters in the URL | | `database error: ...` | Check `TURMITE_DATABASE_URL` and that `postgres` runs | | `internal error: migration failed: ...` | Restore the last dump before you try again, then report the error | ### The worker exits at start | Message | Fix | |---|---| | `set TURMITE_HOT_PASSPHRASE or TURMITE_HOT_PASSPHRASE_FILE to unlock the hot-wallet identity` | Give the passphrase. See [Run without a stored passphrase](/docs/install/#run-without-a-stored-passphrase) | | `wrong hot-wallet passphrase` | Give the passphrase that you used with `turmite init` | | `read /secrets/hot-identity.age: ...` | The file is missing, or the worker user cannot read it. `secrets/` must belong to `65532:65532` | | `the hot-wallet identity cannot read the deposit seed (wrong identity file?)` | The identity in `secrets/` does not belong to this database. Restore `secrets/` from the same install as the database | To give `secrets/` back to the worker user: ```bash sudo chown -R 65532:65532 /opt/turmite/secrets ``` ## Sign-in ### "Too many attempts. Wait a minute." Sign-in allows 10 attempts per minute per IP address and per email address. Wait one minute. If every user sees this message, Turmite sees one IP address for all visitors. Check `TURMITE_TRUSTED_PROXIES` and the proxy headers. See [Client IP addresses](/docs/reverse-proxy/#client-ip-addresses). ### Two-factor codes are refused 1. Check the server clock: `timedatectl`. It must show `System clock synchronized: yes`. 2. Turn on time sync if it is off: `sudo timedatectl set-ntp true`. 3. Wait for the next code. Each code works one time. After 5 wrong codes, Turmite refuses codes for that user for up to 15 minutes: "Too many wrong codes. Wait 15 minutes." ### Lost authenticator device The root user can click **Reset 2FA** for the user under **Server → Users**. Without the root account, run: ```bash sudo docker compose exec api turmite user reset2fa user@example.com ``` The user signs in with the password and enrols again. ### A form fails after sign-in Turmite refuses a dashboard `POST` whose `Origin` (or `Referer`) header does not have the origin of the site URL. The answer is `403 Cross-site request refused.` - Open the dashboard on the host of `TURMITE_SITE_URL`, or on a private entrance that rewrites `Origin`. See [Private dashboard](/docs/private-dashboard/). - Check that your proxy does not remove or change `Origin`. - Check that `TURMITE_SITE_URL` has the correct scheme, host and path. ### The session ends at once On an `https://` site URL, the cookie has the `Secure` flag. A browser does not send it over `http://`. Open the dashboard over `https://`. ## Payments ### A deposit does not show 1. Open **Server → Networks**. The network must show **Connected**, and no **No new block** badge. If it does not, the nodes fail or rate limit you. Add a node. 2. Check that the token is on for that network. 3. Check that the customer used the correct network and the correct token contract. 4. Search the worker log for the address or the transaction hash. ### A payment stays in confirming The deposit needs the confirmation count of the network. A Bitcoin block can take more than an hour. The checkout shows the count, for example `1 of 2`. The defaults are in [Networks and nodes](/docs/nodes/#confirmations). ### The customer sent too little The payment shows `partially_paid`. The checkout shows the missing amount. The customer can send the rest to the same address. Turmite watches the address until one day after the payment window closes. A payment that is still short then becomes `expired`. See [Partial payments](/docs/statuses/#partial-payments). ### The customer paid after expiry A deposit that arrives within one day after the payment window still counts. The payment continues to `finished`. The payment page in the dashboard shows the settlement **Paid late**. Check the price before you ship. Turmite does not watch the address after that day. Check a later deposit on a block explorer and decide by hand: refund with a payout, or use **Mark as paid**. ### The checkout status does not change The status frame reloads every 4 seconds. Check the worker log for the payment. A browser extension that blocks frames can stop the reload. A page reload shows the current status. ## Wallets ### The first Bitcoin addresses do not match Turmite derives native SegWit (BIP84) receive addresses from the account key. Use the account key of a BIP84 account: `xpub` or `zpub` on mainnet, `tpub` or `vpub` on Signet. A key of another account type gives other addresses. ### Monero payments do not show - A restore height above the wallet's creation height hides older payments. - Check the private view key. It must belong to the primary address. - Check that the Monero node is synced. ### An EVM factory deploy fails The project's EVM hot wallet pays the gas for the deploy. Fund it on that network and try again. ## Sweeps ### Tron, Solana or TON funds stay at the deposit address 1. Check the hot wallet balance on that network in **Server → Hot wallets**. 2. Read the worker log for sweep errors. 3. Check that the worker runs. Without the passphrase, the worker does not start, and nothing is swept. | Network | Common cause | |---|---| | Tron | Not enough TRX for energy and bandwidth on a USDT sweep | | Solana | Not enough SOL for fees, or for rent of a new token account | | TON | Not enough TON. The first sweep also deploys the deposit wallet | ### EVM funds stay in the deposit contract The hot wallet needs gas for the flush. A token that is paused or that blocks the address can also fail. Funds in a deposit contract can only go to your cold wallet. ## Payouts ### A payout stays in pending The worker signs payouts. Check that it runs and that it unlocked the hot identity: ```bash sudo docker compose logs --since 1h worker | grep -i -E 'passphrase|identity|payout' ``` ### No approval email - Check `TURMITE_SMTP_URL` and `TURMITE_SMTP_FROM`. - Read the api log for SMTP errors. - Percent-encode special characters in the SMTP password. ### A payout is in needs-review Turmite cannot tell if the transaction landed. Check the hot wallet's history on a block explorer, then resolve the payout in the dashboard. Do not create a new payout before you know. See [Payouts](/docs/payouts/). ## Webhooks ### Every delivery fails - On mainnet, the URL must be public `https`. Turmite refuses private and reserved IP addresses. - Turmite does not follow redirects. Use the final URL. - Your endpoint must answer with a `2xx` status. - Use **Send test** on the Webhooks page to try again. ### The signature does not match - Use the correct secret. A project webhook endpoint signs with its own signing secret, which the Webhooks page shows one time, when you add the endpoint. The IPN of a payment (`ipn_callback_url`) signs with the IPN secret of the project. - For `x-turmite-signature`, compute the HMAC over the raw body bytes. Some frameworks parse the body first and change it. - For the HMAC-SHA512 signature, sort the keys of the JSON body before you sign. See [Webhooks](/docs/webhooks/). - Read the name of the HMAC-SHA512 header in **Settings → General**. The default is `x-turmite-sig`. ### The timestamp is outside your window Your server clock is wrong. Turn on time sync: `sudo timedatectl set-ntp true`. ## Pay with another coin ### The checkout does not show "Pay with another coin" - The install runs on testnet. Trocador swaps mainnet coins only. - The exchange is off, or the project has no Trocador API key. See [Pay with any coin](/docs/exchange/#turn-it-on). - The payment is not `waiting` or `partially_paid`, or its window closed. ### Errors on the swap page | Message | Cause | What to do | |---|---|---| | The amount to send, ..., is less than the exchange minimum | The amount is below the minimum for that coin | The customer chooses another coin or pays directly | | The amount to send, ..., is more than the exchange maximum | The amount is above the maximum for that coin | Same as above | | The exchange has no quote for this coin | No exchange with the KYC rating that you allow offers this pair now | Allow a lower KYC rating, or the customer chooses another coin | | The exchange cannot do this swap now | Trocador refused the trade or did not answer | Try again later. The customer can pay directly | | The exchange returned no deposit address | Trocador answered without an address | Try again | | The exchange cannot send this payment's coin | Trocador does not list the payment's coin and network | The customer pays directly. Or accept a coin that Trocador lists | ### Saving the API key fails "The exchange did not accept the key" or "The exchange refused the API key" means that Trocador refused it. Check the key in your Trocador account. Turmite does not save a refused key. ### The customer paid the exchange, but the payment is not paid Turmite credits only what reaches the deposit address on chain. Open the payment in the dashboard and read its deposits. The customer's swap page shows the swap status and the swap ID. When the exchange sent less than the amount due, the payment is `partially_paid`. A refunded swap sends the coins to the refund address that the customer entered. Without a refund address, the customer contacts the exchange's support with the swap ID. ## Database Open a PostgreSQL shell: ```bash sudo docker compose exec postgres psql -U turmite turmite ``` Do not change rows by hand unless a release note or a maintainer asks you to. Take a dump first. ## Ask for help Open an issue on [GitHub](https://github.com/loruorg/turmite/issues). Add the Turmite version, the mode, the network and the log lines. Remove addresses and order IDs that you do not want to publish. Report security problems privately. See [Reporting a vulnerability](/docs/report-vulnerability/). # Go-live checklist > Check the server, the accounts, the wallets and one real payment per network before you take mainnet payments. Source: https://turmite.com/docs/go-live/ Complete each item before you send real customers to Turmite. ## Server - [ ] The server runs only Turmite. Your shop runs on another machine. - [ ] SSH accepts keys only. `PasswordAuthentication no` is set in `/etc/ssh/sshd_config`. - [ ] Automatic security updates are on (`unattended-upgrades` on Debian and Ubuntu). - [ ] The clock is synchronized. `timedatectl` shows `System clock synchronized: yes`. ## Network and TLS - [ ] The provider firewall (or `DOCKER-USER`) allows only 22 from your addresses, and 80 and 443. See [Firewall](/docs/reverse-proxy/#firewall). - [ ] PostgreSQL and port 8080 are not reachable from the internet. - [ ] `https://pay.example.com/healthz` returns `ok` with a valid certificate. - [ ] Behind Cloudflare: SSL/TLS is **Full (strict)**, and ports 80 and 443 accept only Cloudflare's ranges. - [ ] The audit log shows real visitor IP addresses. See [Client IP addresses](/docs/reverse-proxy/#client-ip-addresses). ## Dashboard and accounts - [ ] `/dashboard` is blocked or returns `404` on every public host name. See [Private dashboard](/docs/private-dashboard/). - [ ] Every user has two-factor authentication. On mainnet, Turmite refuses sensitive actions until it is on. - [ ] Each user has the lowest role that works. No unused admin accounts exist. - [ ] The root password and the root TOTP seed are stored offline. ## Install - [ ] `TURMITE_MODE=mainnet`. - [ ] `TURMITE_SITE_URL` uses `https://`. - [ ] SMTP works. A test payout approval email arrived. - [ ] Update checks are on, or you watch the GitHub releases and advisories. - [ ] `TURMITE_VERSION` in `.env` names the release that you tested. ## Wallets and keys - [ ] Cold wallets are hardware wallets or multisigs. - [ ] Bitcoin: the first receive addresses on the **Wallets** page match the first receive addresses in your wallet. - [ ] Monero: the primary address, view key and restore height are correct. - [ ] EVM: a factory is deployed on each EVM network you accept. A multisig cold wallet exists on each of these networks. - [ ] Tron, Solana, TON: the cold wallet addresses are correct. - [ ] Each hot wallet holds about one week of fees and payouts, and no more. ## Nodes - [ ] Each enabled network has at least two nodes that you run or pay for. - [ ] Each network shows **Connected** and a recent block on **Server → Networks**. - [ ] Confirmation counts are at the defaults or higher. ## Payouts - [ ] **Approve automatically up to**, **Hourly limit** and **Daily limit** are set low, in USD. - [ ] The address book contains only addresses that you checked. - [ ] Only trusted people have the **Project admin** role. ## Integration - [ ] The shop keeps the API key on the server. The key is not in any browser code. - [ ] The webhook handler checks the signature. See [Which signature to use](/docs/webhooks/#which-signature-to-use). - [ ] The handler ships only on `finished`, and it is safe to call twice. - [ ] **Send test** on the Webhooks page reaches the shop. - [ ] The shop keeps the IPN secret and the webhook signing secrets on the server, and reads the signature header that **Settings → General** shows. ## Backups - [ ] `backup.sh` runs each night from root's crontab, and the dumps go off the server. - [ ] `.env`, `secrets/` and the hot wallet passphrase are stored offline. - [ ] You restored a backup on a spare server once. ## Monitoring - [ ] An uptime monitor checks `/healthz`. - [ ] You get an alert for `ERROR` log lines. - [ ] Someone watches hot wallet balances, dead webhook deliveries and payouts in `needs-review`. ## Move from testnet to mainnet The mode is fixed for the life of an install. To go live after tests on testnet: 1. Make a new install with `--mode mainnet` and a new database. See [Install on a server](/docs/install/). 2. Enter mainnet wallets: a mainnet xpub (`xpub` or `zpub`), a mainnet Monero view key and mainnet cold wallet addresses. 3. Add mainnet nodes. 4. Create new API keys, copy the new IPN secret and add the webhook endpoints again. 5. In your shop, set the new base URL, API key and IPN secret. Keep the testnet install for tests of new versions. ## Final test - [ ] One small real payment on each network went from checkout to `finished`. - [ ] The funds arrived in the cold wallet or your own wallet. - [ ] The shop received the webhook and marked the order paid. - [ ] One small payout went through approval and reached its address. # Security model > What a server compromise can and cannot do on each chain, and how Turmite limits the damage. Source: https://turmite.com/docs/security-model/ Turmite keeps the keys that receive money away from the server where the chain allows it. This page tells you what an attacker can do with full control of the server, per chain, and which controls limit the damage. ## What a server compromise can do The table assumes the worst case: an attacker controls the server, the database and every secret on it. On every chain, the attacker can change payment records, mark an unpaid order as paid, and change a project's wallet so that new payments go to the attacker. | Network | Deposit address | The attacker can also | The attacker cannot | |---|---|---|---| | Bitcoin | Receive address of your BIP84 account key, in a watch-only wallet `turmite-p` on your bitcoind | Nothing more | Spend any bitcoin. The server holds no Bitcoin private key | | Monero | Subaddress of a view-only wallet made from your address and private view key | See incoming payments | Spend any monero. The view key cannot sign | | Ethereum, Base, Polygon, BNB Smart Chain | CREATE2 deposit contract per payment | Spend the hot wallet balance | Send a deposit to another address. Each deposit contract can only flush to the cold wallet fixed in its factory | | Tron, Solana, TON | Key per payment, derived from the install's deposit seed | Take deposits that are not yet swept. Spend the hot wallet balance | Take funds that are already in your cold wallet | On Tron, Solana and TON, Turmite holds the deposit keys until the sweep. During that time the funds are custodial. The worker reads the sweep destination, the project's cold wallet, from the database at sweep time. Thus a person who can write to the database, for example through code execution in the `api` process, can change the cold wallet and send the deposits that are not swept yet to another address. The audit log does not record a direct database write. On the EVM networks this is not possible: each deposit contract flushes only to the cold wallet of its factory. To keep the exposure small: - Keep the hot wallet funded, so that sweeps do not stop. - Keep the hot wallet balance small. It only needs fees for sweeps and the payouts you plan. - Prefer the EVM networks, Bitcoin or Monero for large amounts. The Monero wallet files on the wallet RPC host have no password. A person who can read the wallet directory of that host gets the private view key of each project and sees its incoming payments. The person cannot spend. Keep the wallet RPC host private, and give it to Turmite only. A compromised server can lie about payment state. For a large order, check the deposit on a block explorer before you ship. ## Process split Turmite is one binary with two roles. Each role holds different secrets. | Role | Faces the internet | Holds | Does not hold | |---|---|---|---| | `api` | Yes, behind Caddy | `TURMITE_SECRET`, the database password, `TURMITE_HOT_RECIPIENT` (an age public key) | Any private key, the deposit seed, the hot identity, the hot passphrase | | `worker` | No. It opens no port | `TURMITE_SECRET`, the database password, the hot identity file and its passphrase | Nothing more is needed | The `api` role derives a deposit address only where it needs no private key. On Tron, Solana and TON it takes an address from a pool that the worker fills. The `worker` role is the only process that can decrypt hot keys and the deposit seed. The two roles talk only through PostgreSQL. Code execution in the `api` process gives the attacker no private key. It gives the attacker write access to the database. See [Threat model](/docs/threat-model/) T1 and R1. The default Docker Compose stack in `/opt/turmite` runs the roles in separate containers: - Only the `worker` container mounts `secrets/`, read-only. - The `api` and `worker` containers have a read-only file system, drop all Linux capabilities and set `no-new-privileges`. - The image is distroless. It has no shell and no package manager, and it runs as user 65532. - PostgreSQL is on an internal network with no published port. ## Secrets | Secret | Where | Protects | |---|---|---| | `TURMITE_SECRET` | `/opt/turmite/.env` | Seals TOTP secrets, IPN secrets, webhook endpoint secrets, RPC node URLs, Monero view keys and Trocador API keys in the database. Signs payout API tokens. Keys the hashes of payout approval codes | | Hot identity (age) | `/opt/turmite/secrets/hot-identity.age`, encrypted with the passphrase | Decrypts hot wallet keys and the deposit seed | | Hot passphrase | `/opt/turmite/secrets/hot_passphrase` (`TURMITE_HOT_PASSPHRASE_FILE`), or `TURMITE_HOT_PASSPHRASE` at start | Unlocks the hot identity | | Database password | `/opt/turmite/.env` | Read and write access to every row | | Cold wallet keys | Your hardware wallet or multisig | Everything that is swept or paid to you. Turmite never sees them | `turmite init` stores the passphrase in `secrets/hot_passphrase`, so the worker can restart unattended. To keep it off the disk, run `turmite init --no-store-passphrase` and set `TURMITE_HOT_PASSPHRASE` each time the worker starts. The `api` role encrypts every generated or imported hot key to `TURMITE_HOT_RECIPIENT`. It cannot decrypt what it encrypts. A copy of the database alone does not reveal TOTP secrets, IPN and webhook secrets, node URLs, view keys, Trocador keys, hot keys or the deposit seed. Passwords are Argon2id hashes. API keys and session tokens are SHA-256 hashes. Store `.env` and `secrets/` apart from the database backups. See [Backups and restore](/docs/backups/). ## Accounts and sessions - Passwords have at least 12 characters. They are hashed with Argon2id (64 MiB, 3 passes, 1 lane). At most four hashes run at one time, so a flood of logins cannot use all the memory. - Login is limited to 10 attempts a minute per IP address and per email. `POST /v1/auth` shares this limit. - Two-factor authentication (TOTP) is enrolled on the account page. When it is on, login needs a code. - Each code works one time. The last used time step is stored in the database. After 5 wrong codes, two-factor checks for that user are locked for up to 15 minutes. The lock is in the memory of the `api` process and ends when the process restarts. - Sensitive actions need a fresh code, for example API keys, the IPN secret, wallets, hot wallets, nodes, payouts and new users. See [Dashboard and roles](/docs/dashboard/#actions-that-need-a-fresh-code). On mainnet, these actions are refused until two-factor is on. - The session cookie is `turmite_session`. It is `HttpOnly` and `SameSite=Strict`. It is `Secure` when the site URL uses `https`. Its path is the path of the site URL. A session ends after 12 hours without activity, and at the latest 7 days after sign-in. - A disabled user cannot sign in. Turmite ends the user's sessions. - A password change ends the other sessions of the user. - Each process counts the login and API rate limits and the two-factor lockout in its own memory. With more than one `api` process, an attacker gets the limits one time for each process. A restart clears the counts. - Every dashboard `POST` request must carry an `Origin` or `Referer` header with the origin of the site URL. Turmite refuses other requests. - The audit log records each sign-in and each sensitive action with the time, the user or `system`, the project, the action, the target and the IP address. The `root` user can reset two-factor for another user in the dashboard, with a fresh code. On the server, `turmite user reset2fa ` does the same. Both end the user's sessions and write an audit entry. See [CLI](/docs/cli/). Roles limit what each user can do. See [Dashboard and roles](/docs/dashboard/). ## Web security The checkout pages send this content security policy: ```text default-src 'none'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none' ``` The checkout runs no JavaScript. Other sites cannot frame it. Only the live status frame (`/pay/{token}/status`) can be framed, and only by Turmite's own pages. The dashboard has its own policy with `script-src 'self'` and `frame-ancestors 'none'`. It works with JavaScript off. Its only script is optional: it copies text to the clipboard and asks for confirmation before some actions. Turmite adds `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` and `X-Frame-Options` to its responses. Dashboard and checkout pages have `Cache-Control: no-store`. On an `https://` site URL, responses have `Strict-Transport-Security: max-age=31536000`. Turmite pages load nothing from third parties. The fonts are served by Turmite. The QR code is an inline SVG that the server makes. A project logo is an upload or an `https` URL that the server downloads one time. Turmite refuses SVG and files over 2 MB, decodes the image, scales it to fit 512 by 512 pixels and stores it as PNG. The checkout loads the stored copy from Turmite. ## Webhooks - Every delivery is signed. The `ipn_callback_url` of a payment uses the project's IPN secret. Each project endpoint has its own signing secret. See [Webhooks](/docs/webhooks/). - The `x-turmite-signature` header covers the `x-turmite-timestamp` value and the body, so your receiver can refuse replays. - On mainnet, endpoints and logo URLs must be public `https` URLs. Turmite refuses private and reserved IP addresses when it connects. A DNS name that resolves only to such addresses is refused too. - Turmite does not follow redirects. ## Payouts Payouts are the only way Turmite sends money to an address that is not your cold wallet. Bitcoin and Monero have no payouts. These controls apply: - Payouts come only from hot wallets. Keep their balance small. - Limits per project, in USD: a minimum, an auto-approve amount, an hourly limit and a daily limit. By default the auto-approve amount is 0, so every payout waits for approval. - A payout above the auto-approve amount, over the hourly or daily limit, or without a USD rate waits for approval. - Approval needs a project admin's two-factor code. Turmite then emails that admin a 6-digit code. The code works for 10 minutes, 5 attempts and only for that admin. On mainnet, approvals need SMTP. - Turmite does not stop an admin from approving a payout that the same admin created. - A payout made in the dashboard needs a project admin and a fresh code. It can go to any valid address. - An API payout needs the project API key and a bearer token from `POST /v1/auth` for a project admin. The batch waits as `pending-otp-verification` until that admin sends a two-factor code to `POST /v1/payout/{batch_id}/verify`. API payouts can only go to addresses in the address book. - Changes to the address book and the limits need a fresh code. - Each withdrawal can carry a `unique_external_id`. A retry with the same fields returns the existing payout. The same ID with other fields is refused. - The worker signs and stores each transaction before it broadcasts it. When the outcome is unknown, the payout becomes `needs-review` and waits for an admin. - EVM payouts and EVM sweeps from the same hot wallet share one nonce lock. See [Payouts](/docs/payouts/). ## Nodes Turmite trusts its RPC nodes to report deposits correctly. A malicious node cannot move funds, but it can report a deposit that did not occur. Turmite reads each network through one node at a time and does not compare nodes. An EVM node on another chain ID, a Solana node with another genesis hash, and a Tron or TON node on a network of the wrong mode are refused. Use your own nodes or paid providers, and add more than one node per network. See [Networks and nodes](/docs/nodes/). ## Updates and advisories A minute after start and then once a day, the worker reads the latest release and the security advisories of `loruorg/turmite` on GitHub. The requests send the version in the `User-Agent` header and nothing else. Admins see a banner for a new release, and a red banner for an advisory that affects the running version. When SMTP is set, admins get one email for each critical or high advisory. The check is on by default. To turn it off, open **Server → Updates**. This needs a fresh code. See [Upgrades](/docs/upgrades/). GitHub Actions builds each release image from a version tag, with an SBOM and a build provenance attestation. To check an image: ```bash gh attestation verify oci://ghcr.io/loruorg/turmite: --owner loruorg ``` ## Rotate secrets Rotate a secret when a person who knew it leaves, or when you think that it leaked. | Secret | How to rotate | Effect | |---|---|---| | API key | Create a key on **API keys**, put it in your shop, then revoke the old key | A revoked key stops at once | | IPN secret | **Settings → General → IPN secret → Rotate**, with a fresh two-factor code | Each delivery from then on, retries included, is signed with the new secret. Change the secret in your shop at the same time | | Webhook endpoint secret | Add a new endpoint, switch your receiver to it, then delete the old endpoint | The old endpoint gets no more events | | Password | **Account**, with the current password | Ends the user's other sessions | | Two-factor | `turmite user reset2fa `, then enrol again | Ends all sessions of the user. See [CLI](/docs/cli/) | | Node URL or node API key | Add the node with the new URL, then remove the old node | Removing a node needs a fresh two-factor code | | Trocador API key | Enter the new key on **Settings → Exchange** | The old key is no longer used | | Hot wallet key | Make or import a new hot wallet. Send the balance of the old one to it with a payout, then assign the new one to the projects | The old key stays sealed in the database. It holds no balance | | Database password | Run `ALTER ROLE turmite PASSWORD '';` in the `postgres` container. Set the same value in `POSTGRES_PASSWORD` and `TURMITE_DATABASE_URL` in `.env`, then run `docker compose up -d` | The roles connect with the new password | | `TURMITE_SECRET` | No command. See [Environment variables](/docs/environment/#turmite_secret) | Do not change it on a running install | | Deposit seed | Never | Tron, Solana and TON deposit addresses derive from it. Unswept funds at those addresses need it | ## Deposit contracts The EVM deposit contracts are in `contracts/` in the repository. The tests check these properties: - A deposit contract sends native coin and tokens only to the cold wallet of its factory. Anyone can call `flush`. - The cold wallet and the implementation never change. The factory has no owner, no admin and no upgrade path. - A token that fails, blocks the cold wallet, returns `false`, takes a fee on transfer or calls back into the contract does not stop the other tokens and does not move funds elsewhere. - Random calls to a deposit contract cannot move funds. | Test kind | What it does | |---|---| | Unit tests | Each property above, with standard tokens, tokens without a return value, block lists, fee-on-transfer and reentrant tokens | | Fuzz tests | 2000 runs each: random salts, amounts, callers and call data | | Invariant tests | 256 runs of 100 random calls: funding, flushes, block-list changes, reentrant tokens and raw calls. After each call, all funds are at a deposit address or at the cold wallet, and the configuration is the same | | Fork tests | Real USDC and USDT on Ethereum, Base, Polygon and BNB Smart Chain, including a deposit address on the USDC and USDT block lists | To check a deployed factory, build the contracts with the settings in `contracts/foundry.toml` (solc 0.8.38, EVM version cancun, no metadata hash). The bytecode then matches `contracts/out-min/DepositFactory.json`, which the server embeds. That file also gives the formulas for the factory address, the implementation address and each deposit address. Compare the deposit address in the dashboard with the formula before the first payment. ## Operator duties The software cannot do everything. Before you go live: 1. Turn on two-factor authentication for every user. 2. Keep the dashboard private. See [Private dashboard](/docs/private-dashboard/). 3. Use a hardware wallet or a multisig as the cold wallet. 4. Check the first deposit address against your own wallet. See [Wallets per chain](/docs/wallets/). 5. Keep hot wallet balances small and set low payout limits. 6. Back up the database, `.env` and `secrets/`, and store the copies in different places. 7. Use your own or paid RPC nodes, at least two per network. The full list is in the [Go-live checklist](/docs/go-live/). The threats and the remaining risks are in the [Threat model](/docs/threat-model/). # Architecture > How the Turmite binary, its two roles and PostgreSQL work together, and how a payment moves through them. Source: https://turmite.com/docs/architecture/ Turmite is one Rust binary. It runs in two roles, `api` and `worker`, that share one PostgreSQL database. The roles never call each other. Caddy terminates TLS in front of the `api` role. ## Overview ```text customer browser --+ +--> Caddy (TLS) --> api --+ your shop server --+ | +--> PostgreSQL | worker ----+ | +--> RPC nodes, webhook endpoints, Trocador, SMTP, GitHub ``` The `api` role answers HTTP requests. The `worker` role does everything that is slow, periodic or needs a private key. PostgreSQL is the only state they share. Both roles make outbound requests: | Target | `api` | `worker` | |---|---|---| | RPC nodes | Address checks, deposit addresses on Bitcoin, Monero and EVM, node probes every 120 seconds | Chain watching, sweeps, payouts, address pool, node probes every 30 seconds | | CoinGecko, Coinbase | Prices for quotes and payout limits | Prices for EVM sweep batches | | Trocador | Starts swaps. Fetches a swap when Trocador reports a change | Polls open swaps every 10 seconds. Refreshes the coin list once a day | | SMTP server | Payout approval codes | Security advisory emails | | GitHub | **Check now** on the Updates page | The daily update check | | Webhook endpoints | None | Every delivery | | Logo URLs | One download when an admin saves a logo URL | None | ## Roles Start a role with `turmite serve --role all|api|worker`. The default is `all`, which runs both roles in one process. The Docker Compose stack runs `api` and `worker` as separate containers. | Role | Does | Holds a private key | Opens a port | |---|---|---|---| | `api` | Merchant API, checkout pages, dashboard. Gives each payment a deposit address. Encrypts imported or generated hot keys to `TURMITE_HOT_RECIPIENT` | No | Yes, `TURMITE_LISTEN` (default `0.0.0.0:8080`) | | `worker` | Chain watchers, confirmations, expiry, sweeps, payouts, webhook delivery, address pool refill, swap status, update checks | Yes. It is the only process that can decrypt hot keys and the deposit seed | No | The worker loads the hot-wallet identity at start. It does not start when `TURMITE_HOT_IDENTITY_FILE` is set and no passphrase is given. | Loop in the worker | Interval | |---|---| | Check every open payment, then send due webhooks | 5 seconds | | Sign and track payouts | 5 seconds | | Poll open swaps | 10 seconds | | Refill the address pool | 30 seconds | | EVM batch sweeps | 30 seconds | | Probe the RPC nodes | 30 seconds | | Delete expired sessions | 1 hour | | Update check | 1 minute after start, then once a day | Run exactly one `worker` per install, also during an upgrade. Each `api` process keeps its rate limits and its two-factor lockout in its own memory. See [Security model](/docs/security-model/) for why the split matters. ## Source layout | Path | Contents | |---|---| | `crates/turmite/src/main.rs` | CLI entry point: `serve`, `init`, `migrate`, `user reset2fa`, `version`, `healthcheck` | | `crates/turmite/src/init.rs` | `turmite init`: `.env` and `secrets/` | | `crates/turmite/src/http/` | Merchant API, payout API, checkout, dashboard, static assets, middleware | | `crates/turmite/src/db/` | PostgreSQL access, one file per area | | `crates/turmite/src/chains/` | One driver per chain family, the node registry and failover, the EVM factory, the network catalog (`catalog.rs`) | | `crates/turmite/src/worker/` | The background loops: chain watching, confirmations, sweeps, EVM batch sweeps, address pool refill | | `crates/turmite/src/payouts/` | Payout limits, approval codes, the signer loop, one module per chain family | | `crates/turmite/src/exchange/` | The Trocador client and swaps | | `crates/turmite/src/webhooks.rs` | Webhook queue, signatures and delivery | | `crates/turmite/src/auth/` | Passwords, two-factor codes, payout API tokens, rate limits | | `crates/turmite/src/crypto.rs` | Sealing with `TURMITE_SECRET`, age encryption | | `crates/turmite/src/hot.rs` | Hot wallets, encrypted with age | | `crates/turmite/src/netguard.rs` | Outbound HTTP that refuses private addresses on mainnet | | `crates/turmite/src/reducer.rs` | Payment status from the deposits | | `crates/turmite/src/rates.rs` | Exchange rates: CoinGecko first, Coinbase as fallback, cached for 60 seconds | | `crates/turmite/src/updates.rs` | Update checks | | `crates/turmite/src/branding.rs` | Logos, colours, return hosts | | `crates/turmite/src/mail.rs` | Email over SMTP | | `crates/turmite/templates/` | Server-rendered HTML for the dashboard and the checkout | | `crates/turmite/assets/` | Static files embedded in the binary, served at `/assets/` | | `crates/turmite/migrations/` | SQL migrations | | `contracts/` | Foundry project: EVM `DepositFactory` and `DepositWallet` | | `sdk/typescript/` | `@turmite/sdk` | | `plugins/woocommerce/` | WooCommerce gateway plugin | | `deploy/` | Docker Compose, Caddyfile, `install.sh`, Kubernetes manifest, local testnet stack | | `scripts/` | `backup.sh`, `restore.sh`, `e2e.sh` | | `openapi.yaml` | OpenAPI 3.1 description of the merchant API and webhook payloads | ## Data model The main tables, simplified: | Table | Holds | |---|---| | `settings` | Install settings: the deposit seed (age-encrypted to `TURMITE_HOT_RECIPIENT`), the update check state, the nodes in use | | `users`, `sessions` | Dashboard users with a global role, and sessions stored as SHA-256 hashes | | `projects`, `project_members` | One row per shop: name, sealed IPN secret, signature header, payment and invoice windows, branding, return URL and hosts, checkout domain, exchange settings with the sealed Trocador key. Members and their project role | | `api_keys` | Project API keys, stored as SHA-256 hashes with the last four characters | | `audit_log` | Every sign-in and sensitive action | | `asset`, `network`, `asset_network` | Coins, networks (confirmations, enabled, last block) and tickers with decimals and token contracts | | `nodes` | RPC nodes with sealed URLs, a display URL without credentials, a priority and health | | `project_wallets` | Per project and network: address and memo, Bitcoin account key and check addresses, sealed Monero view key and restore height, EVM factory | | `deposit_addresses`, `network_address_counter`, `family_address_counter` | Deposit addresses with their derivation index, the address pool, and the next index per network and per family | | `evm_deposits`, `evm_scans`, `evm_txs` | The factory and cold wallet of each EVM deposit address, its watch state, and the deploy and flush transactions | | `invoices`, `payments` | Invoices and payments with amounts, status, settlement, deposit address and expiry | | `payment_transactions` | Every incoming transfer, with transaction, block and confirmations | | `sweeps` | Transfers from deposit addresses to the cold wallet | | `hot_wallets`, `project_hot_wallets` | age-encrypted hot keys, and the hot wallet of each project per family | | `payout_settings`, `payout_addresses` | Payout limits and the address book of each project | | `payout_batches`, `payouts`, `payout_attempts`, `payout_approval_codes` | Payout requests, their signed transactions, and the emailed approval codes as keyed hashes | | `webhook_endpoints`, `webhook_deliveries` | Endpoints with sealed secrets, and the delivery queue | | `exchange_coins`, `exchange_trades` | The coins Trocador lists, and the swaps customers started | ## Payment lifecycle 1. Your shop calls `POST /v1/payment`, or the customer picks a coin on an invoice page. 2. The `api` role gives the payment a deposit address. See [Address pool](#address-pool). 3. The checkout page shows the amount, the address and a QR code. The status is `waiting`. 4. The worker watches the deposit address through the network's RPC nodes. When it sees a transfer, the status becomes `confirming`. 5. When the transfers with the network's required confirmations cover at least 99 % of the amount, the status becomes `confirmed`. In the same pass the worker credits the funds and the status becomes `finished`. 6. When all transfers are confirmed and cover less than 99 %, the status is `partially_paid`. A top-up can still finish the payment. 7. A payment with no deposit before its expiry becomes `expired`. The worker watches the address for one more day, so a late deposit still counts. A payment that is still underpaid one day after its expiry becomes `expired`. 8. On networks with a sweep, the worker then moves the funds to the cold wallet. The sweep does not change the payment status. Turmite never sets `sending`. 9. Each status change queues a webhook delivery. With the exchange option, the customer pays an exchange through Trocador. The `api` role starts the swap. The exchange sends the payment coin to the same deposit address, and the steps from 4 continue as above. See [Pay with any coin](/docs/exchange/). See [Payment statuses](/docs/statuses/) for every status. ## Address pool The `api` role derives a deposit address itself when it needs no private key for it. On Tron, Solana and TON the address comes from a key of the install's deposit seed. Only the worker holds that seed, so the worker keeps a pool of 25 free addresses per network, and the `api` role takes one from the pool. When the pool is empty, the API answers 503 with `Retry-After: 30`. With `--role all`, the one process derives every address directly. | Family | Deposit address | |---|---| | Bitcoin | Next BIP84 receive address of the project's account key, from a watch-only wallet `turmite-p` on bitcoind | | Monero | New subaddress of the project's view-only wallet in monero-wallet-rpc. Turmite checks the view key against the address itself | | EVM | CREATE2 address computed from the project's factory and an install-wide salt. The contract is deployed at the first sweep | | Tron, Solana, TON | Next key from the install's deposit seed, from the pool. The private key stays with the worker | ## Sweeps | Family | Sweep | |---|---| | Bitcoin, Monero | None. Funds arrive in your own wallet | | EVM | The worker sends one `flush` per project, network and factory from the project's EVM hot wallet. It deploys the factory first when the chain has no code at its address. The factory deploys missing deposit contracts, and they send to the cold wallet fixed in the factory. The hot wallet pays the gas | | Tron | The worker signs a transfer with the deposit key. For TRC-20 the hot wallet first sends the TRX that the transfer burns. A TRX deposit pays its own fee | | Solana | The worker signs a transfer with the deposit key. A SOL deposit pays its own fee. For SPL tokens the hot wallet pays the fee and creates the cold wallet's token account if needed. The empty deposit token account is closed and its rent goes to the hot wallet | | TON | The first sweep deploys the wallet v4r2 contract at the deposit address and sends the whole balance. A settlement memo goes as a text comment | An EVM batch goes out when gas is at most 2 % of its value, when it is worth 1000 USD or more, when it has 150 deposits, or when its oldest payment waited 6 hours. It never goes out while gas is more than 20 % of its value. A failed EVM flush is tried again after one hour. On Tron, Solana and TON the worker sweeps each payment on its own, after it credits the payment. A sweep that fails before the broadcast is tried again on the next pass. The worker then checks each broadcast sweep on chain until it is final. A sweep that failed on chain, or that the chain does not have 10 minutes after the broadcast, gives its amount back, and the worker sends it again: at once after each of the first 3 failures, then once per hour. A sweep that can still land keeps its amount, so the worker never sends the same funds twice. After the watch window of a payment ends, the worker still checks its open sweeps and sweeps funds that stay at the deposit address, every 60 seconds. Only the worker signs sweeps. Without a hot wallet, or without gas in it, EVM, TRC-20 and SPL sweeps stop and the funds stay at the deposit addresses. ## Nodes and failover Each network has a list of RPC nodes, edited under **Server → Networks**. Node URLs are sealed with `TURMITE_SECRET`. The dashboard and the audit log show them without credentials. At start, each process connects to the first node in list order that answers. The worker probes every enabled node every 30 seconds, the `api` every 120 seconds. When the node in use fails two probes in a row, or falls too far behind the best node, the process moves the network to the next healthy node. After two good probes of a node higher in the list, it moves back. The worker records each move in the audit log as `node.failover`. Both processes connect again when the node list changes. The Networks page shows each node's status, height, lag, last check and last error, the node in use, and the last block of each network. See [Networks and nodes](/docs/nodes/). ## Webhook delivery Each event becomes a row in `webhook_deliveries`. The worker sends due rows, signs each attempt and records the response. Failed attempts are retried on a fixed schedule. After the last attempt, the delivery is `dead`. See [Webhooks](/docs/webhooks/). ## Payouts The `api` role stores a payout request and checks it against the limits and the address book. The worker signs approved payouts with the hot wallet key, stores each signed transaction, then broadcasts it and tracks it to a final status. EVM payouts and EVM sweeps from the same hot wallet share one nonce lock. See [Payouts](/docs/payouts/). ## Update checks A minute after start and then once a day, the worker reads the latest release and the security advisories of `loruorg/turmite` on GitHub. The result is stored in `settings`. The dashboard shows a banner to admins when a newer release or an advisory exists. The check is on by default. Turn it off under **Server → Updates**. ## Migrations `turmite migrate` applies the SQL migrations and exits. `turmite serve` applies them at start too, in every role. # Threat model > The assets Turmite protects, its trust boundaries, each threat with its mitigation, and the risks that remain. Source: https://turmite.com/docs/threat-model/ This page lists what Turmite protects, where the trust boundaries are, and how each threat is handled. The last section lists the risks that remain. The [Security model](/docs/security-model/) gives the short version. ## Assets | Asset | Where it is | Impact if an attacker gets it | |---|---|---| | Customer deposits on EVM networks | CREATE2 deposit contracts | Delayed sweeps. A deposit contract can only flush to the cold wallet of its factory, so the attacker cannot take the funds | | Customer deposits on Tron, Solana, TON | Per-payment keys from the deposit seed | Loss of deposits that are not yet swept | | Bitcoin and Monero receipts | Your own wallet | None. The server holds no spend key | | Cold wallets | Your hardware wallet or multisig | Total loss. Turmite never holds these keys | | Hot wallets | PostgreSQL (`hot_wallets`), age-encrypted. Only the worker can decrypt | Loss of the hot balance | | Deposit seed | PostgreSQL (`settings`), age-encrypted to `TURMITE_HOT_RECIPIENT`. Only the worker can decrypt | Loss of unswept deposits on Tron, Solana, TON | | `TURMITE_SECRET` | `/opt/turmite/.env`, in the `api` and `worker` containers | Forged payout API tokens. With a copy of the database: TOTP secrets, IPN and webhook secrets, node URLs, view keys and Trocador keys | | Hot identity and passphrase | `/opt/turmite/secrets/`, in the `worker` container only | With a copy of the database: hot keys and the deposit seed | | Dashboard accounts | Argon2id hashes and sealed TOTP secrets in PostgreSQL | Payouts, wallet changes and configuration changes, as the role allows | | API keys | SHA-256 hashes in PostgreSQL | Payment and invoice creation. API payouts also need a project admin's password and two-factor code | | Payment state and webhooks | PostgreSQL and your endpoint | Your shop ships an order that is not paid | ## Trust boundaries 1. Internet to Caddy to `api`. The `api` role holds no private key. It can store payout requests. It cannot sign them. 2. `api` to PostgreSQL to `worker`. The two roles share only the database. A compromised `api` can write rows. The worker signs what the database holds. 3. Turmite to RPC nodes. Nodes are not trusted with keys. They are trusted to report deposits. 4. Turmite to webhook endpoints and logo URLs. On mainnet, outbound requests go only to public `https` addresses. 5. Turmite to your cold wallet. Turmite knows only addresses and public keys. On EVM networks the factory address is computed from the cold wallet (CREATE2). The same cold wallet always gives the same factory, and no other code can be at that address. The worker deploys the factory from the project's EVM hot wallet when you click **Deploy now**, or before the first sweep. You can also send the deploy transaction from your own wallet. ## Threats and mitigations | # | Threat | Mitigation | |---|---|---| | T1 | Code execution in the `api` process | The `api` container holds no private key, no deposit seed and no hot passphrase. It holds `TURMITE_SECRET` and the database password, so the attacker can do what T4 describes and read the sealed secrets. Residual: R1, R2 | | T2 | Code execution in the `worker` process | The worker opens no port, which makes it harder to reach. If it is reached, the attacker gets the hot wallets and unswept deposits on Tron, Solana and TON. Residual: R2, R3 | | T3 | Read access to the database | Passwords use Argon2id. TOTP secrets, IPN and webhook secrets, node URLs, view keys and Trocador keys are sealed with `TURMITE_SECRET`, which is not in the database. Hot keys and the deposit seed are age-encrypted to an identity that a passphrase protects. Neither the identity nor the passphrase is in the database. API keys and session tokens are SHA-256 hashes. Payout approval codes are keyed hashes | | T4 | Write access to the database | The attacker can change payment state and set payouts to `pending`, which the worker signs. The attacker can change a project's wallets, so that new payments and unswept Tron, Solana and TON deposits go to the attacker. The attacker cannot change where existing EVM deposits go. Residual: R1, R2 | | T5 | Stolen dashboard password | Login needs a TOTP code when two-factor is on. Sensitive actions need a fresh code. On mainnet they are refused without two-factor. After 5 wrong codes, checks lock for up to 15 minutes. Login is limited to 10 attempts a minute per IP and per email | | T6 | Stolen API key | The key can create payments and invoices in its own project and read them. API payouts also need a bearer token from `POST /v1/auth` with a project admin's email and password, then that admin's two-factor code. API payouts go only to address-book entries. Payouts above the limits wait for approval | | T7 | Malicious or faulty RPC node | Several nodes per network. The worker probes them every 30 seconds and moves to the next node after two failed probes, or when a node falls too far behind. EVM nodes on another chain ID, Solana nodes with another genesis hash, and Tron and TON nodes on a network of the wrong mode are refused. Node URLs are sealed. The dashboard and the audit log show them without credentials. Residual: R4 | | T8 | Chain reorganisation | A deposit counts only after the network's required confirmations. A Bitcoin deposit that leaves the mempool without a block stops counting | | T9 | Forged or replayed webhook | Every delivery has an HMAC-SHA512 signature over the key-sorted body and an HMAC-SHA256 signature over the timestamp and the raw body. Receivers can refuse old timestamps | | T10 | Server-side request forgery through webhook or logo URLs | On mainnet only public `https` URLs. Private and reserved IP addresses are refused at connect time, after DNS resolution. Redirects are not followed | | T11 | Cross-site scripting or framing of the checkout | No JavaScript on the checkout. A strict content security policy with `default-src 'none'` and `frame-ancestors 'none'`. Server-side templates escape output | | T12 | Cross-site request forgery on the dashboard | `SameSite=Strict` session cookie. Every dashboard `POST` must carry an `Origin` or `Referer` with the origin of the site URL | | T13 | Brute force and resource exhaustion | Login limit of 10 a minute per IP and per email. TOTP lockout. At most four password hashes at one time. 600 merchant API requests and 300 checkout requests a minute per IP. `X-Forwarded-For` is trusted only from `TURMITE_TRUSTED_PROXIES` | | T14 | Testnet install pointed at mainnet nodes, or the reverse | `TURMITE_MODE` seeds the networks and token contracts of one mode. EVM, Solana, Tron and TON nodes of the wrong chain are refused. A testnet install accepts a local Tron or TON chain. A Bitcoin account key must match the mode and the node's network. A mainnet install refuses Monero test-network addresses | | T15 | Double payout | Each withdrawal can carry a `unique_external_id`. The worker signs and stores a transaction before it broadcasts it. When the outcome is unknown, the payout becomes `needs-review`. EVM payouts and sweeps share a nonce lock per hot wallet | | T16 | Phishing through a checkout domain | Only a server admin with a fresh code sets a checkout domain. On that host Turmite serves only the checkout, the merchant API, assets and `/healthz`. The dashboard is not served there | | T17 | Hostile customer input on the checkout | Checkout forms take only the coin choice and, for a swap, a coin search and a refund address with an optional memo. Forms post to the same origin only (`form-action 'self'`) | | T18 | Trocador or an exchange reports a false status, sends less than the amount due or sends nothing | Turmite does not trust the exchange. The exchange delivers to the payment's own deposit address, and the normal watcher credits only what arrives on chain with the required confirmations. The swap status is for display only. A call from Trocador only makes Turmite fetch the swap again with the project's key. The Trocador API key is sealed with `TURMITE_SECRET`. Residual: R6 | | T19 | Stolen backups | Database dumps contain sealed secrets, encrypted hot keys and the encrypted deposit seed. They are useless without `TURMITE_SECRET` and the hot identity with its passphrase. Store `.env` and `secrets/` apart from the dumps | | T20 | Vulnerable release in use | The worker checks GitHub releases and security advisories once a day. Admins see a banner, and an email for critical and high advisories when SMTP is set | ## Residual risks - R1. Database write access can approve payouts. Approval state lives in PostgreSQL, and the worker trusts it. Code execution in the `api` process gives this access. Keep the hot balance small and keep PostgreSQL off the network. The Docker Compose stack does not publish its port. - R2. Tron, Solana and TON deposits are custodial until the sweep. Anyone with the deposit seed can move them. Anyone who can write the database can change where the worker sweeps them. Keep sweeps running, and use the EVM networks, Bitcoin or Monero for large amounts. - R3. Hot wallets can be spent by anyone who controls the worker. Keep about a week of fees and planned payouts in them, no more. - R4. Turmite trusts the node that answers to report a deposit. It does not need several nodes to agree. Use your own nodes or paid providers, and check large orders on a block explorer before you ship. - R5. An admin can approve a payout that the same admin created. Turmite does not require a second person. Teams should agree that a second person approves. - R6. During a swap through Trocador, the exchange holds the customer's coins. If the swap fails or halts, the customer loses time or money until the exchange refunds. An exchange with KYC rating C or D can hold the coins for an identity check. Turmite never credits a payment without an on-chain deposit. See [Pay with any coin](/docs/exchange/). - R7. The TOTP lockout and the rate limits are held in the memory of each process. A restart clears them, and with more than one `api` process each process counts on its own. Each code still works only one time, because the last used step is in the database. - R8. Bitcoin payments depend on a correct account key. Compare the first receive addresses that the Wallets page shows with your own wallet. See [Wallets per chain](/docs/wallets/). - R9. `turmite init` stores the hot passphrase in `secrets/hot_passphrase`, so the worker restarts unattended. A copy of `secrets/` then holds both the identity and its passphrase. If your risk model needs it, run `turmite init --no-store-passphrase` and set `TURMITE_HOT_PASSPHRASE` at each start. ## Out of scope - The security of your cold wallet and its seed. - The security of your shop and of the server that receives webhooks. - Attacks on the blockchains themselves. - A malicious operator. The operator controls the server and can change the software. # Reporting a vulnerability > Report a security problem in private through a GitHub security advisory. Source: https://turmite.com/docs/report-vulnerability/ Report security problems in private. Turmite uses GitHub private vulnerability reports on `loruorg/turmite`. ## How to report 1. Open [github.com/loruorg/turmite/security/advisories/new](https://github.com/loruorg/turmite/security/advisories/new). 2. Write a short title and a description. 3. Submit the report. Only the maintainers can see it. You need a GitHub account. The same address is in [/.well-known/security.txt](/.well-known/security.txt). Do not open a public issue, pull request or discussion for a security problem. Do not post it in a chat or on social media before a fix is released. ## What to include - The Turmite version (`turmite version`) and the install method. - The mode: `mainnet` or `testnet`. - The affected component: merchant API, checkout, dashboard, worker, EVM contracts, SDK, WooCommerce plugin, `install.sh` or this site. - The steps to reproduce the problem, with requests and responses if you have them. - The impact: what an attacker can do, and what access the attacker needs first. - A proposed fix, if you have one. Remove real API keys, secrets, private keys and customer data from the report. ## Scope In scope: - The code in the `loruorg/turmite` repository. - The container image `ghcr.io/loruorg/turmite`. - The EVM deposit contracts in `contracts/`. - `@turmite/sdk` and the WooCommerce plugin. - The install script at `https://turmite.com/install.sh`. - The site `turmite.com`. Out of scope: - Problems that need full control of the server first. See [Threat model](/docs/threat-model/) for what such an attacker can already do. - Misconfiguration of a single install, for example a public dashboard without two-factor authentication. - Third-party software and services: PostgreSQL, Caddy, Docker, RPC nodes, Trocador and the exchanges it lists. Report these to their maintainers. - Denial of service by traffic volume. ## What happens next 1. A maintainer acknowledges the report. 2. The maintainers confirm the problem and agree on a severity with you. 3. The maintainers prepare a fix in a private fork of the advisory. 4. A new release ships the fix. The advisory is published at the same time, with credit to you if you want it. Turmite is a community project. There is no bug bounty. ## How fixes reach operators Each install reads the releases and security advisories of `loruorg/turmite` on GitHub once a day, unless an admin turned the check off. When an advisory affects the running version, admins see a red banner in the dashboard. When SMTP is set, admins also get an email for each critical or high advisory. To install the fix, follow [Upgrades](/docs/upgrades/). Without a running install, watch the releases of the repository on GitHub (**Watch**, **Custom**, **Releases**) and read the [advisories](https://github.com/loruorg/turmite/security/advisories). # CLI > Reference for the turmite command and its subcommands. Source: https://turmite.com/docs/cli/ Turmite is one binary, `turmite`. The Docker image runs it as its entrypoint. On an install made with `install.sh`, run commands as root from `/opt/turmite` through `docker compose`. - In a running container, give the full command: `docker compose exec api turmite version`. - In a new container, give only the subcommand, because the entrypoint is `turmite`: `docker compose run --rm api migrate`. `turmite` with no command runs `turmite serve`. The logs are JSON lines when the output is not a terminal. | Command | Does | |---|---| | `turmite serve` | Runs the server | | `turmite init` | Writes `.env` and `secrets/` for a new install | | `turmite migrate` | Runs the database migrations | | `turmite user reset2fa ` | Turns off two-factor for one user | | `turmite counters raise --by ` | After a database restore, skips deposit address indexes | | `turmite version` | Prints the version | | `turmite healthcheck` | Checks that the local server answers | `turmite --help` and `turmite --help` print the options. ## turmite serve ```text turmite serve [--role all|api|worker] [--demo] ``` | Option | Meaning | |---|---| | `--role all` | The default. Runs the API and the worker in one process. For small installs and local tests | | `--role api` | Merchant API, checkout pages and dashboard. Holds no private key | | `--role worker` | Chain watchers, confirmations, expiry, sweeps, payouts, webhook delivery, address pool refill, exchange swaps and update checks. Opens no port | | `--demo` | Uses mock chains in place of real nodes. For a local trial only. See [Quick start](/docs/quick-start/) | The `api` role listens on `TURMITE_LISTEN` (default `0.0.0.0:8080`). Only the `worker` role can decrypt hot keys and the deposit seed. The install script runs the two roles in separate containers, so the process that faces the internet holds no key. Migrations run when `serve` starts. They are in the binary. | Role | Reads | |---|---| | `api` | `TURMITE_DATABASE_URL`, `TURMITE_SITE_URL`, `TURMITE_SECRET`, `TURMITE_MODE`, `TURMITE_HOT_RECIPIENT`, `TURMITE_LISTEN`, `TURMITE_TRUSTED_PROXIES`, `TURMITE_SMTP_URL`, `TURMITE_SMTP_FROM` | | `worker` | `TURMITE_DATABASE_URL`, `TURMITE_SITE_URL`, `TURMITE_SECRET`, `TURMITE_MODE`, `TURMITE_HOT_IDENTITY_FILE`, `TURMITE_HOT_PASSPHRASE` or `TURMITE_HOT_PASSPHRASE_FILE`, `TURMITE_SMTP_URL`, `TURMITE_SMTP_FROM` | | `all` | The union of both | Start-up checks: - Every role refuses to start without `TURMITE_SECRET`, except with `--demo`. - Every role refuses to start on mainnet with an `http://` site URL, and when `TURMITE_MODE` differs from the mode of the database. - The `worker` and `all` roles refuse to start when `TURMITE_HOT_IDENTITY_FILE` is set and no passphrase is given, or when the passphrase is wrong. - The `api` role does not check `TURMITE_HOT_RECIPIENT` at start. Without it, the dashboard cannot generate or import hot keys. With `--demo`, `TURMITE_SECRET` is optional, the mode is always `testnet`, and the `TURMITE_HOT_*` variables are not used. See [Environment variables](/docs/environment/). Examples: ```bash # One process, for a local test turmite serve # Two processes, as the install script runs them turmite serve --role api turmite serve --role worker # Mock chains on your computer turmite serve --demo ``` Run one `worker` per install. Two workers process the same payments at the same time. You can run more than one `api` process. Each `api` process counts rate limits and wrong two-factor codes on its own. ## turmite init Writes the configuration for a new install: `.env` and the `secrets/` directory. The install script runs it for you. ```text turmite init --site-url URL [--mode testnet|mainnet] [--dir DIR] [--no-store-passphrase] [--owner UID:GID] [--force] ``` | Option | Meaning | |---|---| | `--site-url` | Required. The public URL, for example `https://pay.example.com`. Mainnet needs `https://` | | `--mode` | `testnet` (the default) or `mainnet`. The install script uses `mainnet` by default | | `--dir` | The directory with `docker-compose.yml`. The default is the current directory. In the image it is `/data` | | `--no-store-passphrase` | Do not write the passphrase to disk. You then give `TURMITE_HOT_PASSPHRASE` at start | | `--owner` | When `init` runs as root, give `secrets/` and its files to this user and group. The default is `65532:65532`, the user of the image | | `--force` | Replace an existing `.env` and identity. The old hot keys then become unreadable | It does these steps: 1. It asks for a hot-wallet passphrase of at least 12 characters, two times. It uses `TURMITE_HOT_PASSPHRASE` instead when that variable is set. Without a terminal and without the variable, it stops. 2. It makes an age identity, encrypts it with the passphrase and writes it to `secrets/hot-identity.age`. 3. It writes the passphrase to `secrets/hot_passphrase`, so the worker can restart without you. `--no-store-passphrase` skips this step. 4. It sets mode `700` on `secrets/`. As root, it gives `secrets/` to the `--owner` user. 5. It writes `.env` with the version, the mode, the site URL, a random `TURMITE_SECRET`, the public key of the identity as `TURMITE_HOT_RECIPIENT`, the file paths for the worker, a random database password, `TURMITE_DATABASE_URL` and the Caddy site address. The files get mode `600`. `turmite init` refuses to overwrite an existing `.env` or identity without `--force`. Keep a copy of `.env` and `secrets/` offline. See [Backups and restore](/docs/backups/). To run it from the image: ```bash sudo docker run --rm -it --user 0:0 -v /opt/turmite:/data ghcr.io/loruorg/turmite:0.1.0 \ init --mode mainnet --site-url https://pay.example.com ``` ## turmite migrate Runs the database migrations, prints `migrations applied` and exits. Needs `TURMITE_DATABASE_URL` and `TURMITE_SECRET`. `turmite serve` runs the migrations at start, so you do not need this command in normal use. Use it to migrate before you start a new version: ```bash cd /opt/turmite sudo docker compose run --rm api migrate ``` Take a database dump first. See [Upgrades](/docs/upgrades/). ## turmite user reset2fa ```text turmite user reset2fa ``` Turns off two-factor authentication for the user with this email. The user then signs in with the password and enrols again on the account page. Needs `TURMITE_DATABASE_URL` and `TURMITE_SECRET`. ```bash cd /opt/turmite sudo docker compose exec api turmite user reset2fa ops@example.com ``` The command also ends all sessions of the user and writes a `user.2fa_reset` entry to the audit log. Use it when a user loses the authenticator. Check the identity of the person before you run it. Anyone who can run this command has shell access to the server. The root user can also reset two-factor for another user on the **Users** page. ## turmite counters raise ```text turmite counters raise --by ``` Use it after you restore a database dump. It adds `n` to each deposit address counter (per network, per EVM family and per project wallet) and deletes the free addresses of the address pool. New payments then do not get the addresses that Turmite gave out after the dump. The worker makes new pool addresses. `n` is 1 to 1000000. Needs `TURMITE_DATABASE_URL` and `TURMITE_SECRET`. Use an `n` larger than the number of payments made after the dump. On Bitcoin, the skipped addresses are a gap in your wallet: set the gap limit of your wallet above `n`. See [Backups and restore](/docs/backups/#after-a-restore). The command writes a `counters.raise` entry to the audit log. ## turmite version Prints `turmite` and the version, for example `turmite 0.1.0`, and exits. ```bash sudo docker compose exec api turmite version ``` The dashboard shows the same version on the **Updates** page. ## turmite healthcheck Sends `GET /healthz` to the server on `TURMITE_LISTEN`, through the loopback address. It exits with `0` when the answer is `200` and `ok`, and with `1` in other cases. The image has no `curl`, so its Docker `HEALTHCHECK` runs this command every 30 seconds. ```bash sudo docker compose exec api turmite healthcheck ``` # Environment variables > The complete list of environment variables that Turmite reads. Source: https://turmite.com/docs/environment/ Turmite reads a short list of environment variables. They hold secrets and values that differ between installs. All other settings live in the database. You edit them in the dashboard. See [Configuration](/docs/configuration/). On an install made with `install.sh`, the variables are in `/opt/turmite/.env`. After you change the file, run `sudo docker compose up -d` in that directory. ## All variables | Variable | Read by | Default | Purpose | |---|---|---|---| | `TURMITE_DATABASE_URL` | api, worker | `postgres://turmite:turmite@127.0.0.1:5433/turmite` | PostgreSQL URL | | `TURMITE_SITE_URL` | api, worker | `http://localhost:8080` | Public URL. Can have a path | | `TURMITE_SECRET` | api, worker | None. Required, except with `--demo` | 64 hex characters. Seals stored secrets | | `TURMITE_MODE` | api, worker | `testnet` | `mainnet` or `testnet` | | `TURMITE_LISTEN` | api | `0.0.0.0:8080` | Bind address | | `TURMITE_TRUSTED_PROXIES` | api, Caddy | Private ranges | Proxies whose `X-Forwarded-For` is trusted | | `TURMITE_HOT_RECIPIENT` | api | None | age public key. The api encrypts hot keys to it | | `TURMITE_HOT_IDENTITY_FILE` | worker | None | Path to the age identity, encrypted with the passphrase | | `TURMITE_HOT_PASSPHRASE` | worker | None | The passphrase for the identity | | `TURMITE_HOT_PASSPHRASE_FILE` | worker | None | A file that holds the passphrase | | `TURMITE_SMTP_URL` | api, worker | None | SMTP server URL with credentials. Needed on mainnet | | `TURMITE_SMTP_FROM` | api, worker | `turmite@localhost` | Sender address | With `turmite serve --role all`, one process reads all of them. An empty value counts as not set. The defaults suit a local test. On a server, `turmite init` writes every value that the server needs. `RUST_LOG` sets the log filter. The default is `info,sqlx=warn,tower_http=warn`. The compose file does not pass it. Docker Compose and Caddy read three more values from `.env`. Turmite does not read them: | Variable | Purpose | |---|---| | `TURMITE_VERSION` | The image tag, for example `0.1.0`. `install.sh --upgrade` changes it | | `POSTGRES_PASSWORD` | The password of the `turmite` database user | | `TURMITE_CADDY_ADDRESS` | The Caddy site address: the host name of the site URL, or `http://:80` for plain HTTP | ## Details ### TURMITE_DATABASE_URL The PostgreSQL connection URL. ```dotenv TURMITE_DATABASE_URL=postgres://turmite:PASSWORD@postgres:5432/turmite ``` Percent-encode special characters in the password. `turmite init` makes a random hex password. ### TURMITE_SITE_URL The public URL of the server. Turmite uses it for checkout links, the session cookie and the `Origin` check on dashboard forms. ```dotenv TURMITE_SITE_URL=https://pay.example.com ``` The URL can have a path, for example `https://example.com/pay`. Turmite then serves everything under that path. See [Run under a path](/docs/path-prefix/). On mainnet, Turmite refuses to start with an `http://` site URL. Turmite removes a trailing slash. The URL must not have a query or a fragment. ### TURMITE_SECRET 64 hex characters (32 bytes). `turmite init` makes one. To make one by hand: ```bash openssl rand -hex 32 ``` It seals the TOTP secrets, the IPN secrets, the webhook endpoint secrets, the node URLs, the Monero view keys and the exchange API keys in the database. It also signs the step between password and code at sign-in, the payout approval codes and the tokens of `POST /v1/auth`. Keep a copy offline and keep the same value for the life of the install. Turmite has no command to change it. With another value, the sealed data is unreadable: users with two-factor cannot sign in until an operator runs `turmite user reset2fa`, and you must enter nodes, view keys, webhook endpoints and exchange keys again and rotate the IPN secrets. ### TURMITE_MODE `mainnet` or `testnet`. The mode decides which networks Turmite seeds in the database. Turmite stores the mode in the database at the first start. If `TURMITE_MODE` differs later, Turmite refuses to start: "this database was set up for testnet; TURMITE_MODE is mainnet. Use a new database for the other mode." ### TURMITE_LISTEN The address and port that the `api` process binds to. Default `0.0.0.0:8080`. `turmite healthcheck` connects to the same port. The compose file does not pass this variable, so the `api` container listens on 8080. ### TURMITE_TRUSTED_PROXIES The proxies whose `X-Forwarded-For` header Turmite trusts. Turmite takes the client IP address from that header only when the connection comes from one of these ranges. Rate limits and the audit log use the client IP address. The default is `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `::1/128` and `fc00::/7`. That fits the bundled Caddy on the Docker network. A list that you set replaces the default. Turmite accepts ranges and single IP addresses, separated by spaces or commas. The bundled Caddy reads the same variable for its own `trusted_proxies`, and Caddy needs spaces. Use spaces: ```dotenv TURMITE_TRUSTED_PROXIES=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 173.245.48.0/20 ``` See [Reverse proxy, Cloudflare and firewall](/docs/reverse-proxy/#client-ip-addresses). ### TURMITE_HOT_RECIPIENT An age public key (`age1...`). When you generate or import a hot key in the dashboard, the `api` encrypts it to this key. The `api` cannot decrypt it. `turmite init` writes this value. ### TURMITE_HOT_IDENTITY_FILE The path to the age identity that matches `TURMITE_HOT_RECIPIENT`. The file is encrypted with the hot-wallet passphrase. Only the `worker` reads it. ```dotenv TURMITE_HOT_IDENTITY_FILE=/secrets/hot-identity.age ``` The compose file mounts `secrets/` read-only at `/secrets` in the `worker` container only. ### TURMITE_HOT_PASSPHRASE and TURMITE_HOT_PASSPHRASE_FILE The passphrase that decrypts the identity. Set one of the two. Only the `worker` reads them. - `TURMITE_HOT_PASSPHRASE_FILE` points to a file. The worker can then restart without you. - `TURMITE_HOT_PASSPHRASE` holds the passphrase. Use it to type the passphrase at start. See [Run without a stored passphrase](/docs/install/#run-without-a-stored-passphrase). When both are set, `TURMITE_HOT_PASSPHRASE` wins. When `TURMITE_HOT_IDENTITY_FILE` is set and neither is set, the worker refuses to start. A wrong passphrase stops the start with "wrong hot-wallet passphrase". ### TURMITE_SMTP_URL The SMTP server, with credentials in the URL. ```dotenv # TLS on port 465 TURMITE_SMTP_URL=smtps://user:password@smtp.example.com:465 # STARTTLS on port 587 TURMITE_SMTP_URL=smtp://user:password@smtp.example.com:587?tls=required ``` A `smtp://` URL without `?tls=required` sends mail without encryption. Percent-encode special characters in the user name and the password, for example `@` as `%40`. Turmite sends two kinds of email: payout approval codes, and notices of critical or high security advisories to root and admin users. On testnet, SMTP is optional: without it, Turmite writes each email to the log. On mainnet, Turmite starts without SMTP, but each approval code fails with "Email is not set up. Set TURMITE_SMTP_URL.", so held payouts cannot be approved. ### TURMITE_SMTP_FROM The sender address of the emails, with or without a name. ```dotenv TURMITE_SMTP_FROM=Turmite ``` Use an address on a domain with SPF and DKIM for your SMTP provider. Otherwise the codes can land in spam. # Payment statuses > Every payment and payout status, what it means and what your shop does. Source: https://turmite.com/docs/statuses/ A payment has one status in `payment_status`. Turmite sends a webhook at each change. This page lists each status and what your shop does. Payout statuses are at the end. ## Payment statuses | Status | Meaning | Final | What your shop does | |---|---|---|---| | `waiting` | The payment exists. No deposit is seen yet | No | Show the payment page | | `confirming` | A deposit is seen on the chain. It does not have the required confirmations yet | No | Show "processing". Do not ship | | `confirmed` | The confirmed deposits cover the amount | No | Wait for `finished`. It usually follows in the same worker pass | | `partially_paid` | All deposits are confirmed, but they are less than the amount | No | Wait. The customer can send the rest | | `finished` | The payment is complete | Yes | Ship the order | | `refunded` | A project admin marked the payment as refunded | Yes | Cancel the order | | `expired` | The payment window closed without enough funds | Yes | Cancel the order or create a new payment | | `failed` | Turmite does not set this status. It is in the API for compatibility | Yes | Do not ship | | `sending` | Turmite does not set this status. It is in the API for compatibility | No | Wait for `finished` | Ship only on `finished`. The `isPaid()` function of the [TypeScript SDK](/docs/sdk/) returns true only for `finished`. A payment counts as paid when the confirmed amount is at least 99 % of `pay_amount`. This tolerance absorbs wallet rounding. Compare `actually_paid` with `pay_amount` if you need the exact amount. ### Partial payments A payment is `partially_paid` when its deposits are less than 99 % of `pay_amount` and all of them have the required confirmations. The checkout page shows "Partly paid" and the amount still due. The customer sends the rest to the same address. When the total is enough, the payment continues to `confirmed` and `finished`. Turmite watches the address for one day after the payment window closes. A payment that is still short at that time becomes `expired`. The funds that arrived go to your wallet. Decide by hand: refund with a payout, or accept the order with **Mark as paid**. ### Payments per chain The payment becomes `finished` as soon as its confirmed deposits cover the amount. On the swept chains, Turmite then moves the funds from the deposit address to your wallet. The sweep does not change the payment status. `payout_hash` shows the sweep transaction. | Network | After `finished` | |---|---| | Bitcoin, Monero | Nothing. The funds are already in your wallet | | Ethereum, Base, Polygon, BNB Smart Chain | The deposit contract flushes to your cold wallet | | Tron, Solana, TON | The worker sweeps the deposit key to your cold wallet | ### State diagram ```text waiting -> confirming -> confirmed -> finished waiting -> confirmed the first deposit already has its confirmations waiting -> expired no deposit before the payment window closes confirming -> partially_paid less than the amount, all of it confirmed partially_paid -> confirming the customer sends the rest partially_paid -> expired still short one day after the window closes expired -> confirming a deposit arrives within one day after the window any status except finished and refunded -> finished Mark as paid finished, partially_paid, failed, expired with a deposit -> refunded Mark as refunded ``` After `finished`, a further deposit within one day after the window sends the `finished` webhook again with the new `actually_paid`. When the deposit is confirmed, the webhook comes again with the new `outcome_amount`. ### Expiry `expiration_estimate_date` is the end of the payment window. The project setting **Payment window (minutes)** sets it. The default is 60 minutes. The allowed range is 5 to 10080 minutes (7 days). The countdown on the payment page uses the same time. A deposit that arrives within one day after the window still counts. The payment continues to `finished`, and the dashboard shows "Paid late" in the **Settlement** field of the payment. Check late payments before you ship, because the price can have changed. ### Manual actions On the payment page in the dashboard, a project admin can change a payment by hand under **Manual actions**. Each action needs a fresh two-factor code, writes an audit log entry and sends a webhook. | Action | Shown for | New status | Use it when | |---|---|---|---| | **Mark as paid** | Every status except `finished` and `refunded` | `finished` | The customer paid in another way, or you checked the funds yourself | | **Mark as refunded** | `finished`, `partially_paid`, `failed`, and `expired` when a deposit arrived | `refunded` | You sent the funds back with a payout or from your wallet | Turmite does not send funds back by itself. ### Webhook retries per status Turmite retries a webhook that does not get a `2xx` response: | Kind | Statuses | Attempts | |---|---|---| | Progress | `waiting`, `confirming`, `confirmed`, `partially_paid` | 3: now, 10 s, 60 s | | Final | `finished`, `refunded`, `expired` | 11: now, 10 s, 1 m, 5 m, 30 m, 1 h, 2 h, 4 h, 8 h, 24 h, 48 h | See [Webhooks](/docs/webhooks/#retries). ## Payout statuses A payout has one of these statuses. Each status change after creation sends a `payout.` webhook. | Status | Meaning | Final | |---|---|---| | `pending-otp-verification` | An API batch waits for `POST /v1/payout/{batch_id}/verify`, or an admin approval waits for the emailed code | No | | `pending-approval` | A project limit holds the payout until a project admin approves it | No | | `pending` | Cleared to send. It waits for the worker | No | | `signing` | The worker signs the transaction and stores it before broadcast | No | | `initiated` | A node accepted the transaction. It waits for confirmations | No | | `sent` | The transaction has the required confirmations | Yes | | `failed` | The transaction did not happen | Yes | | `rejected` | A project admin rejected the payout | Yes | | `cancelled` | A project admin cancelled the payout before signing | Yes | | `needs-review` | Turmite cannot tell whether the transaction landed. A project admin must check and resolve it | No | Do not create the same payout again while it is `needs-review`. A second payout can pay twice. See [Payouts](/docs/payouts/#needs-review). # Changelog > Changes in each Turmite release. Source: https://turmite.com/docs/changelog/ Each release is on [GitHub Releases](https://github.com/loruorg/turmite/releases). The image is `ghcr.io/loruorg/turmite:`. Security fixes are also published as [security advisories](https://github.com/loruorg/turmite/security/advisories). ## 0.1.0 The first public release. - Merchant API with the paths, fields, statuses and IPN signature of the common hosted-gateway API. - Networks: Bitcoin, Monero, Ethereum, Base, Polygon, BNB Smart Chain, Tron, Solana and TON, with USDC and USDT where listed. - Image for `linux/amd64` and `linux/arm64`. - Watch-only Bitcoin from a BIP84 xpub. View-only Monero from an address and a private view key. - CREATE2 deposit contracts on the EVM networks, locked to the cold wallet in the project's factory. - Per-payment keys on Tron, Solana and TON, swept to the cold wallet. - Checkout pages for invoices and payments that work with JavaScript off. - Per-payment IPN callbacks and project webhook endpoints. Each delivery has the compatible HMAC-SHA512 signature and a timestamped HMAC-SHA256 signature. - Payouts from hot wallets with an address book, limits and approval by TOTP and an emailed code. - Optional payment with any coin that Trocador lists, delivered to the payment's own deposit address. - Server-rendered dashboard with roles, two-factor authentication and an audit log. - Projects with branding, checkout domains and a return URL. - Site URL with a path prefix. - Daily update checks against GitHub releases and advisories. - RPC node health checks and failover. - Install script with Docker Compose and Caddy, with a database dump before each upgrade. `turmite init` for new installs. Backup and restore scripts. - TypeScript SDK `@turmite/sdk` and a WooCommerce plugin. # Contributing > Build Turmite from source, run the tests and send a pull request. Source: https://turmite.com/docs/contributing/ Turmite is a community project. The code, the issues and the pull requests are on [GitHub](https://github.com/loruorg/turmite). Contributions are under the MIT license. Report security problems in private. Do not open a public issue. See [Reporting a vulnerability](/docs/report-vulnerability/). ## Repository layout | Path | Contents | |---|---| | `crates/turmite/` | The library and the `turmite` binary (Rust 2024) | | `crates/turmite/src/http/` | Merchant API, checkout, dashboard, assets, middleware | | `crates/turmite/src/chains/` | One driver per chain family, the mock driver, the network catalog, node health and failover | | `crates/turmite/src/worker/` | Background loops: chain watching, sweeps, the EVM sweeper, the address pool | | `crates/turmite/src/payouts/` | Payouts: limits, approvals, signing per chain | | `crates/turmite/src/exchange/` | Pay with any coin through Trocador | | `crates/turmite/src/db/` | Database access, one file per area | | `crates/turmite/templates/` | Server-rendered templates: dashboard and checkout | | `crates/turmite/migrations/` | Database migrations | | `contracts/` | Foundry project: EVM `DepositFactory` and `DepositWallet` | | `sdk/typescript/` | `@turmite/sdk` | | `plugins/woocommerce/` | The WooCommerce plugin | | `deploy/` | Compose file, Caddyfile, `install.sh`, Kubernetes manifests, testnet stack | | `site/` | This website | | `openapi.yaml` | OpenAPI 3.1 description of the merchant API and the webhook payloads | ## Requirements - Rust 1.99.1. `rust-toolchain.toml` selects it when you use `rustup`. - Docker with the Compose plugin, for PostgreSQL 18 and the testnet stack. - Foundry, only to change the EVM contracts. - Node.js 26 and pnpm 12, only for the SDK and the site. CI uses these versions. ## Build and run 1. Clone the repository: ```bash git clone https://github.com/loruorg/turmite.git cd turmite ``` 2. Start PostgreSQL. It listens on `127.0.0.1:5433`: ```bash docker compose -f deploy/testnets/docker-compose.yml up -d postgres ``` 3. Run the server with mock chains: ```bash cargo run -- serve --demo ``` The defaults of `TURMITE_DATABASE_URL` and `TURMITE_SITE_URL` point to this database and to `http://localhost:8080`. With `--demo`, Turmite makes a random `TURMITE_SECRET` when none is set. 4. Open `http://localhost:8080/dashboard` and create the first user. ## Tests ```bash cargo test ``` The tests need the PostgreSQL server from step 2. Each test makes its own database there. To use another server, set `TURMITE_TEST_PG=postgres://user:pass@host:port`. The default features build every chain, so the tests need no feature flag. The real-chain tests skip unless `TURMITE_TEST_CHAINS` names their chain (`bitcoin,monero`) or is `all`. They need the [local testnet stack](#local-testnet-stack). Before you send a pull request, also run: ```bash cargo fmt --all --check cargo clippy --locked --all-targets -- -D warnings ``` For the EVM contracts: ```bash cd contracts && forge test ``` Do not change the deployed contract bytecode without a migration plan. The factory bytecode decides every EVM deposit address. ## Local testnet stack `deploy/testnets` runs PostgreSQL and a local node for each chain. All images run on `arm64` and `amd64`. ```bash docker compose -f deploy/testnets/docker-compose.yml up -d # PostgreSQL, Bitcoin, Monero, EVM docker compose -f deploy/testnets/docker-compose.yml --profile all up -d # adds Solana, Tron, TON deploy/testnets/verify.sh # checks each local node scripts/e2e.sh # every test, with the real-chain tests on every chain ``` `scripts/e2e.sh` fails when a real-chain test skips. The profile `public` adds clients for the public testnets (Bitcoin Signet, Monero Stagenet). Optional settings, such as API keys for `verify.sh --public`, go in `deploy/testnets/.env`. See `.env.example`. `deploy/testnets/README.md` lists the ports, the funding commands and the faucets. ## Pull requests - Keep each pull request to one change. - Add or update tests for the change. - Update the docs in `site/src/content/docs/` when behaviour changes. - Write user-facing text in ASD-STE100 Simplified Technical English: short sentences, active voice, one fact per sentence. - Do not add telemetry, analytics or third-party trackers. - Do not add an environment variable that turns a feature on or off. Settings belong in the dashboard. Open an issue first if you think a new variable is necessary. ## The website The site is an Astro project in `site/`: ```bash cd site pnpm install pnpm dev ``` `pnpm build` writes the static site to `site/dist/` and builds the search index. # License > Turmite is free software under the MIT license. Source: https://turmite.com/docs/license/ Turmite is free software under the MIT license. You can use and change it for any purpose, including commercial use. There is no paid edition and no fee. The license file is [`LICENSE`](https://github.com/loruorg/turmite/blob/main/LICENSE) in the repository. Third-party dependencies keep their own licenses. ## MIT License ```text MIT License Copyright (c) 2026 Turmite contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ## What the license means for operators - You can run Turmite for your own shops and for shops of other people. - You can change the code. You do not have to publish your changes. - If you give copies of Turmite to other people, include the license text. - The software comes with no warranty. You are responsible for your server, your keys and your funds.