Hosted signing
Sign with tx/sign, tx/send and address/new by sending a key: the encrypted envelope, the required header and the limits.
# Hosted signing
Hosted signing lets Rarebl sign for you: you send a private key with the request, and the signer builds, signs or generates keys in memory and forgets the key. It is a good fit for a quick script or an environment that cannot run an SDK. The key reaches Rarebl over TLS, wrapped in an encrypted envelope that is bound to one chain, one operation and what may be signed; signing locally with an SDK keeps keys on your side ([Sign locally with the SDKs](https://docs.rarebl.com/guides/sign-locally)).
The three endpoints:
| Endpoint | What it does |
| --- | --- |
| [tx/sign](https://docs.rarebl.com/api/tx/sign) | Signs an `unsigned` transaction from `tx/build`. Never broadcasts. |
| [tx/send](https://docs.rarebl.com/api/tx/send) | Builds, signs and broadcasts in one call. |
| [address/new](https://docs.rarebl.com/api/address/new) | Generates a new keypair in the signer and returns it once. |
Every hosted request needs the header `X-Rarebl-Accept-Risk: hosted-signing`. Without it the API answers `API403` with reason `risk_not_acknowledged`.
## Before you start
- A key to sign with, as a WIF or 64-character hex string, for an address that holds funds.
- SDK 0.2.0 or later ([JavaScript](https://docs.rarebl.com/sdks/js), [Python](https://docs.rarebl.com/sdks/python) or [PHP](https://docs.rarebl.com/sdks/php)), which builds the envelope, or your own X25519 and AES-GCM code (format at the end of step 3). Envelope v1, which earlier SDK versions produced, is refused with `bad_envelope`.
- Try It is disabled for these endpoints on this site, so that a private key is never typed into a web page. Use your own server.
## Step 1 — Fetch the signer key
The signer publishes a public X25519 key. Encrypt your private key to it so that only the signer process can read it; the web server, the firewall and PHP only see ciphertext.
```bash cURL
curl https://api.rarebl.com/open/tx/signer-key
```
```js JavaScript
import { Rarebl, envelope } from './rarebl.mjs';
const rarebl = new Rarebl();
const signer = await rarebl.signerKey(); // {kid, alg, publicKey, expiresAt}: pass it whole to envelope.encrypt
```
```python Python
import os, rarebl
c = rarebl.Rarebl()
signer = c.signer_key() # {kid, alg, publicKey, expiresAt}: pass it whole to envelope.encrypt
```
```php PHP
<?php
require 'Rarebl.php';
use Rarebl\{Rarebl, Envelope};
$c = new Rarebl();
$signer = $c->signerKey(); // ['kid' => ..., 'publicKey' => ..., 'expiresAt' => ...]
```
**You should see** a key id, the algorithm and the public key. Keys rotate: the previous `kid` stays valid for 7 days after its successor was created, and a key is refused 7 days after its `expiresAt`. Fetch the key when you need it instead of hard-coding it.
```json
{ "status": 1, "code": "API200", "message": "success",
"data": { "kid": "k20261115-defd9c", "alg": "X25519", "publicKey": "wL-KbhcrI3WWpG1nv8sNAoiWUVTyw9yMxfn1_jvoxT4", "expiresAt": "2026-12-15T00:12:34.514Z" },
"go": null, "meta": { "requestid": "0123456789abcdef", "notices": [] } }
```
## Step 2 — Build the transaction (tx/sign only)
For [tx/sign](https://docs.rarebl.com/api/tx/sign) you sign a transaction you built with [tx/build](https://docs.rarebl.com/api/tx/build), and the envelope names the digests of that build. Build it, then check the reply as in [Send a transaction](https://docs.rarebl.com/guides/send-a-transaction) before you let anyone sign it. With [tx/send](https://docs.rarebl.com/api/tx/send) there is nothing to build first: skip to step 3 and name the recipients instead.
```js JavaScript
import { describeBuild } from './rarebl.mjs';
const to = 'bc1qanfrvw5lunlemvm09dthnxmwknal0fw0q7jfnm';
const reply = await rarebl.build({ chain: 'btc', from: [from], to: [{ address: to, amount: '0.0015' }], priority: 'standard' });
console.log(describeBuild(reply, { expect: { to: [{ address: to, amount: '0.0015' }] } })); // throws VerifyError on a mismatch
```
```python Python
to = "bc1qanfrvw5lunlemvm09dthnxmwknal0fw0q7jfnm"
reply = c.build({"chain": "btc", "from": [from_addr], "to": [{"address": to, "amount": "0.0015"}], "priority": "standard"})
rarebl.verify_build(reply, expect={"to": {"address": to, "amount": "0.0015"}}) # raises VerifyError on a mismatch
```
```php PHP
$to = 'bc1qanfrvw5lunlemvm09dthnxmwknal0fw0q7jfnm';
$reply = $c->build(['chain' => 'btc', 'from' => [$from], 'to' => [['address' => $to, 'amount' => '0.0015']], 'priority' => 'standard']);
Rarebl\Signer::verifyBuild($reply, null, ['to' => ['address' => $to, 'amount' => '0.0015']]); // throws VerifyException on a mismatch
```
**You should see** no error, and `reply.toSign` with one digest per input.
## Step 3 — Encrypt the key into an envelope
The envelope carries your private key together with the rules the signer enforces. It is bound to:
- **the chain** (`chain`), which must be the chain of the request;
- **the operation** (`op`): `sign` for `tx/sign`, `send` for `tx/send`;
- **what may be signed**: for `sign`, the exact list of `toSign` digests (`digests`); for `send`, the recipients and amounts (`bind.to`, as base-unit integers, plus `bind.token` for a token transfer);
- **a lifetime**: it expires within 300 seconds (`exp`) and opens once (`jti`).
The SDKs fill in `exp` and `jti`. For `tx/sign`, name the digests of the build you checked:
```js JavaScript
const digests = reply.toSign.map((t) => t.digest); // the digests you just verified
const encrypted = await envelope.encrypt(signer, {
privateKeys: [process.env.BTC_WIF], op: 'sign', chain: 'btc', digests,
});
```
```python Python
digests = [t["digest"] for t in reply["toSign"]] # the digests you just verified
encrypted = rarebl.envelope.encrypt(signer, {
"privateKeys": [os.environ["BTC_WIF"]], "op": "sign", "chain": "btc", "digests": digests,
}) # needs the `cryptography` package for AES-GCM
```
```php PHP
$digests = array_column($reply['toSign'], 'digest'); // the digests you just verified
$encrypted = Envelope::encrypt($signer, [
'privateKeys' => [getenv('BTC_WIF')], 'op' => 'sign', 'chain' => 'btc', 'digests' => $digests,
]);
```
For `tx/send`, name the recipients. `bindOf` converts whole-unit amounts to base units (satoshi, wei, sun or token units):
```js JavaScript
const bind = envelope.bindOf('btc', [{ address: to, amount: '0.0015' }]); // token transfers: { token: '0x…' } as the third argument
const encrypted = await envelope.encrypt(signer, {
privateKeys: [process.env.BTC_WIF], op: 'send', chain: 'btc', bind,
});
```
```python Python
bind = rarebl.envelope.bind_of("btc", {"address": to, "amount": "0.0015"}) # token transfers: token="0x…"
encrypted = rarebl.envelope.encrypt(signer, {
"privateKeys": [os.environ["BTC_WIF"]], "op": "send", "chain": "btc", "bind": bind,
})
```
```php PHP
$bind = Envelope::bindOf('btc', ['address' => $to, 'amount' => '0.0015']); // token transfers: the contract as the third argument
$encrypted = Envelope::encrypt($signer, [
'privateKeys' => [getenv('BTC_WIF')], 'op' => 'send', 'chain' => 'btc', 'bind' => $bind,
]);
```
**You should see** a long base64url string. Send it as `keys.encrypted`.
If you build the envelope yourself, it is `base64url(JSON {v:2, kid, epk, iv, ct, tag})` with every field base64url without padding. Generate a fresh ephemeral X25519 key `epk`, derive the shared secret with the signer's public key, then HKDF-SHA256 with an empty salt and the info `"rarebl-signer-v2" || epk || recipientPub` (the ASCII label, your 32-byte `epk`, the signer's 32-byte public key, all raw) to get a 32-byte key. Encrypt with AES-256-GCM, a 12-byte IV and the AAD `info || utf8(kid)`; `ct` is the ciphertext and `tag` the 16-byte tag. The plaintext is JSON:
```json
{ "v": 2, "privateKeys": ["<key>"], "exp": 1794000000, "jti": "<32 lowercase hex>", "op": "sign", "chain": "btc", "digests": ["<hex>"] }
```
`exp` is in unix seconds and must be at most 300 seconds ahead. For `op: "send"` replace `digests` with `"bind": {"to": [{"address": "…", "amount": "150000"}]}` (at most 10 recipients; `"max"` is accepted on UTXO chains) and add `"token": "<contract>"` inside `bind` for a token transfer.
A plain `{"privateKeys": [...]}` body is still accepted, but it carries no binding: nothing limits what the key signs, and anything on the path can read it. Prefer the envelope.
## Step 4 — Sign, or build, sign and send
Use [tx/sign](https://docs.rarebl.com/api/tx/sign) with the `unsigned` transaction from step 2, then broadcast the result yourself. Or use [tx/send](https://docs.rarebl.com/api/tx/send) for one call that builds, signs and broadcasts: the body is a `tx/build` body plus `keys`.
```bash cURL
curl -X POST https://api.rarebl.com/open/tx/send \
-H "Content-Type: application/json" \
-H "X-Rarebl-Accept-Risk: hosted-signing" \
-d '{
"chain": "btc",
"from": ["bc1q7dszvzdj8lk8dd4ssad94m0t2acunaf48j455q"],
"to": [{"address": "bc1qanfrvw5lunlemvm09dthnxmwknal0fw0q7jfnm", "amount": "0.0015"}],
"priority": "standard",
"keys": { "encrypted": "BASE64URL_ENVELOPE" }
}'
```
```js JavaScript
const hdr = { 'X-Rarebl-Accept-Risk': 'hosted-signing' };
// tx/send: the envelope was made with op 'send'
const sent = await rarebl.request('POST', '/open/tx/send', {
chain: 'btc', from: [from], to: [{ address: to, amount: '0.0015' }], priority: 'standard',
keys: { encrypted },
}, hdr);
// tx/sign: the envelope was made with op 'sign'
const signedOnly = await rarebl.request('POST', '/open/tx/sign', { unsigned: reply.unsigned, keys: { encrypted } }, hdr);
```
```python Python
hdr = {"X-Rarebl-Accept-Risk": "hosted-signing"}
# tx/send: the envelope was made with op "send"
sent = c.post("/open/tx/send", {
"chain": "btc", "from": [from_addr], "to": [{"address": to, "amount": "0.0015"}], "priority": "standard",
"keys": {"encrypted": encrypted},
}, headers=hdr)
# tx/sign: the envelope was made with op "sign"
signed_only = c.post("/open/tx/sign", {"unsigned": reply["unsigned"], "keys": {"encrypted": encrypted}}, headers=hdr)
```
```php PHP
$hdr = ['X-Rarebl-Accept-Risk' => 'hosted-signing'];
// tx/send: the envelope was made with op 'send'
$sent = $c->post('/open/tx/send', [
'chain' => 'btc', 'from' => [$from], 'to' => [['address' => $to, 'amount' => '0.0015']], 'priority' => 'standard',
'keys' => ['encrypted' => $encrypted],
], $hdr);
// tx/sign: the envelope was made with op 'sign'
$signedOnly = $c->post('/open/tx/sign', ['unsigned' => $reply['unsigned'], 'keys' => ['encrypted' => $encrypted]], $hdr);
```
**You should see** `signed` and, for `tx/send`, the `broadcast` result. `tx/sign` returns only `signed`: broadcast it with [Broadcast](https://docs.rarebl.com/api/tx/broadcast).
```json
{ "signed": { "chain": "btc", "hex": "…", "txid": "…" }, "broadcast": { "txid": "…", "accepted": true, "alreadyKnown": false } }
```
An envelope opens once. If a request fails after the signer read it (a refusal or a timeout), make a new envelope for the retry: resending the same one answers `envelope_replayed`.
## Step 5 — Generate a key (optional)
[address/new](https://docs.rarebl.com/api/address/new) generates a keypair in the signer and returns it once. Pass `encryptTo` (your own X25519 public key, base64url) to get the private key back as an envelope encrypted to you (`encryptedPrivateKey`); open it with `envelope.decrypt` (`Envelope::decrypt` in PHP). Generating keys locally with the SDK (`keys.generate`) avoids the round trip.
## How it is protected
These rules are enforced by the API:
| Rule | What it means |
| --- | --- |
| Risk header | `X-Rarebl-Accept-Risk: hosted-signing` on every request, otherwise `API403` `risk_not_acknowledged`. |
| Scoped envelope | An envelope is bound to its chain and operation, expires within 300 seconds and opens once. `sign` signs only the digests it names; `send` pays only the recipients and amounts it names, and any other output must return to an input address. |
| Body only | Keys are accepted in a `POST` body only. A key-like value in the query string gives `API400` `plaintext_key_in_query` and the request is dropped unprocessed. |
| Key matching | Every key must derive the address of an input it signs (`key_mismatch`); unused keys are refused. |
| No retention | A separate signer process (unix socket, no network) uses the key in memory and wipes it. Keys are never written to disk, logs, the database, caches or error reports, and request bodies on these paths are excluded from all logging. |
| Key rotation | The signer key rotates; a retired key is refused 7 days after its `expiresAt`. |
| Limits | The write rate class (6 per minute, burst 3, 300 per day) and at most 10 inputs per request. |
| Replies | Always `Cache-Control: no-store`. |
Only the txid, chain, time and a hashed IP of a broadcast are recorded.
## Common errors
| Reply | What it means | What to do |
| --- | --- | --- |
| `API403` `risk_not_acknowledged` | The header is missing. | Add `X-Rarebl-Accept-Risk: hosted-signing`. |
| `API403` `key_mismatch` | A key does not derive the address of an input it signs. | Send only the keys of the input addresses. |
| `API403` `digest_not_bound` | `tx/sign`: the digests of the `unsigned` transaction are not exactly the ones in the envelope. Nothing was signed. | List every `toSign` digest of this build, and only those, in `digests`. |
| `API403` `outputs_not_bound` | `tx/send`: the transaction does not pay exactly the recipients and amounts in `bind` (or has an output that does not return to an input address). Nothing was signed. | Make `bind` match the `to` of the request, in base units; use `envelope.bindOf`. |
| `API400` `bad_envelope` | The envelope is unreadable, is a v1 envelope, names an unknown `kid`, has an `op` that does not match the route, or an `exp` more than 300 seconds ahead. | Fetch the signer key again and encrypt again with SDK 0.2.0 or later. |
| `API400` `envelope_expired` | The envelope's `exp` has passed. | Make a new envelope right before the request. |
| `API409` `envelope_replayed` | The envelope was already used. | Make a new envelope for every request. |
| `API400` `chain_mismatch` | The envelope's `chain` is not the chain of the request. | Use the same chain in the envelope and the request. |
| `API400` `plaintext_key_in_query` | A key-like value was in the URL. | Move it into the POST body (as an envelope). |
| `API400` with `param: "keys"` | A `keys` member is malformed. | Send `{"encrypted": "…"}` or `{"privateKeys": [...]}`. |
| `API422` `tx_too_large` | More than 10 inputs. | Split the spend. |
| `API502` `signer_unavailable` | The signer is not reachable. | Retry with back-off, with a new envelope. |
| `API429` | Write rate limit. | Wait `Retry-After` seconds. |
When a request answers `bad_envelope` or an unknown `kid`, fetch [Signer key](https://docs.rarebl.com/api/tx/signer-key) again before you retry: the key may have rotated. Build and broadcast errors (`insufficient_funds`, `node_rejected` and others) are the same as in [Send a transaction](https://docs.rarebl.com/guides/send-a-transaction).
## Next steps
- The local alternative: [Sign locally with the SDKs](https://docs.rarebl.com/guides/sign-locally) and [Send a transaction](https://docs.rarebl.com/guides/send-a-transaction).
- Endpoint details: [tx/sign](https://docs.rarebl.com/api/tx/sign), [tx/send](https://docs.rarebl.com/api/tx/send), [address/new](https://docs.rarebl.com/api/address/new), [Signer key](https://docs.rarebl.com/api/tx/signer-key).