Auth: workspace token. Scope: sign on every route.
All four address a public key directly. Resolution is scoped to the session’s own
workspace, so one tenant can never sign with another’s key — and an address that
exists elsewhere returns the same 404 as one that does not exist at all.
POST /v1/sign/message
Returns 200.
Request
{
"address": "0xabc…",
"message": "hello from tee-docker",
"encoding": "personal_sign"
}| Field | Type | Required | Notes |
|---|---|---|---|
address |
string | yes | Public key, 1–128 characters |
message |
string | yes | UTF-8 text for every encoding except raw, which decodes it as hex |
encoding |
enum | no | personal_sign, raw, or ed25519 — each restricted to one address kind |
How message is read depends on the encoding, and getting it backwards yields a
valid signature over the wrong bytes. Every encoding except raw reads it as
UTF-8 text, so "0xdeadbeef" is signed as those ten characters. raw decodes it
as hex, so the same string is signed as the four bytes it spells.
Each explicit encoding belongs to one address kind, and asking for the wrong one
returns 501 unsupported_operation:
encoding |
Address kind | What is signed |
|---|---|---|
| omitted | any | The UTF-8 text, using that address’s own default scheme. No messageHash comes back. |
personal_sign |
EVM only | The UTF-8 text, with the standard Ethereum message prefix. |
raw |
EVM only | The keccak-256 hash of the decoded bytes, with no prefix. message must be 0x-prefixed hex of even length. |
ed25519 |
Solana only | The UTF-8 text, signed directly. |
Omitting encoding works for both address kinds, so it is the portable choice
when one code path signs with either.
Response
{ "address": "0xabc…", "signature": "0x…" }With an explicit encoding, the response also carries messageHash:
{ "address": "0xabc…", "signature": "0x…", "messageHash": "0x…" }For personal_sign and raw that is the digest actually signed. For ed25519
it is the message bytes as hex — ed25519 signs the message directly rather than
a prehash, so there is no digest to report.
POST /v1/sign/typed-data
EIP-712 typed data. EVM only. Returns 200.
Request
{
"address": "0xabc…",
"typedData": {
"domain": { "name": "MyDapp", "version": "1", "chainId": 1, "verifyingContract": "0xdef…" },
"types": { "Order": [ { "name": "maker", "type": "address" } ] },
"primaryType": "Order",
"message": { "maker": "0xabc…" }
},
"chainId": 1
}| Field | Type | Required | Notes |
|---|---|---|---|
address |
string | yes | Public key, 1–128 characters |
typedData |
object | yes | The EIP-712 payload, as an object — see below |
chainId |
integer | no | Positive. Defaults to the address’s own network |
typedData needs domain, types, primaryType and message. primaryType
must exactly name one of the structs in types, matching case. Struct types you
never reference are ignored.
This service does not inspect the domain object’s keys. It reads only the exact
lowercase chainId for the chain check below, so a mis-cased key such as chainID
— which a JSON round-trip can easily produce — is read as absent: no
chain_id_mismatch fires on it, and the domain is passed to the signing engine as
sent. Get the field names right; the service will not catch a typo here.
Four things that catch people out
Response
{
"address": "0xabc…",
"signature": "0x…",
"domainSeparator": "0x…",
"structHash": "0x…"
}Both intermediate hashes are returned so you can verify the domain binding independently.
A non-EVM address returns 422 with unsupported_for_kind.
POST /v1/sign/digest
A raw secp256k1 signature over a caller-supplied 32-byte digest. EVM only.
Returns 200.
This signs the digest verbatim — no keccak, no EIP-191 prefix. You RLP-encode
and hash the transaction yourself, the service signs the digest, and you broadcast.
It is the parity mode /sign/message’s raw encoding does not provide: raw
hashes what you send, this signs it as-is.
Request
{
"address": "0xabc…",
"digest": "0x9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658"
}| Field | Type | Required | Notes |
|---|---|---|---|
address |
string | yes | Public key, 1–128 characters |
digest |
string | yes | Lowercase 0x-hex of exactly 32 bytes (^0x[0-9a-f]{64}$) |
The digest format is strict on purpose: a signature is a bearer instrument, so
an ambiguous or wrong-length input is refused rather than padded or re-hashed.
Unknown fields are rejected.
Response
{
"address": "0xabc…",
"r": "0x…",
"s": "0x…",
"recovery": 0,
"signature": "0x…"
}signature is the 65-byte r ‖ s ‖ v form with v = 27 + recovery. recovery
is the yParity (0 or 1) on its own, so you can build whichever v your
transaction type needs.
A non-EVM address returns 422 with unsupported_for_kind.
POST /v1/sign/bytes
A raw ed25519 signature over caller-supplied bytes. Solana only. Returns 200.
This signs the exact bytes verbatim. You compile the Solana message yourself, the service signs it, and you attach the signature and broadcast.
Request
{
"address": "So1abc…",
"message": "0x0102feff",
"encoding": "hex"
}| Field | Type | Required | Notes |
|---|---|---|---|
address |
string | yes | Public key, 1–128 characters |
message |
string | yes | The bytes to sign, 1 byte to 16 KiB. Lowercase 0x-hex of whole bytes by default, or base64 |
encoding |
enum | no | hex (default) or base64 |
hex must be lowercase, 0x-prefixed, and an even number of digits. base64
must round-trip exactly — a lenient value that decodes to different bytes than it
re-encodes to is rejected rather than signed. Both name the same bytes; the
encoding only says how message is written. Unknown fields are rejected.
Response
{
"address": "So1abc…",
"signature": "5f2…",
"signatureHex": "0x…"
}signature is the 64-byte ed25519 signature as base58 (the form a Solana
transaction carries); signatureHex is the same 64 bytes as 0x-hex.
A non-Solana address returns 422 with unsupported_for_kind.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_body |
The body or the EIP-712 envelope is the wrong shape. The message names what failed. |
| 400 | chain_id_mismatch |
domain.chainId disagrees with the chain being signed for. |
| 400 | invalid_parameter |
The signing engine rejected the payload — a bad field type, or an address failing its checksum. |
| 401 | session_expired |
|
| 403 | scope_denied |
The token lacks sign, or it is a scoped API key (the sign routes are workspace-token-only). |
| 404 | account_not_found |
No such address in this workspace. |
| 422 | unsupported_for_kind |
Typed data or a digest on a non-EVM address, or bytes on a non-SVM address. |
| 423 | account_locked |
|
| 501 | unsupported_operation |
An encoding this address kind cannot perform. |
invalid_body means this service rejected the request shape; invalid_parameter
means it passed that check and the signing engine rejected the contents. The
split is useful when narrowing down a payload that will not sign.
Related
- Sign messages — the task walkthrough
- Parameter effects and outputs — per-parameter effects and response fields