signal-cli Documentation

Signal-cli provides an unofficial commandline, JSON-RPC and dbus interface for the Signal messenger.

4,545 stars Java Java 1 file ยท ~6,081 tokens #commandline#dbus#java#json-rpc#messaging#signal#signal-cli
RAW Doc

File: README.md

signal-cli

signal-cli is a commandline interface for the Signal messenger.
It supports registering, verifying, sending and receiving messages.
signal-cli uses a patched libsignal-service-java,
extracted from the Signal-Android source code.
For registering you need a phone number where you can receive SMS or incoming calls.

signal-cli is primarily intended to be used on servers to notify admins of important events.
For this use-case, it has a daemon mode with JSON-RPC interface (man page)
and D-BUS interface (man page).
For the JSON-RPC interface there's also a simple example client, written in Rust.

signal-cli needs to be kept up-to-date to keep up with Signal-Server changes.
The official Signal clients expire after three months and then the Signal-Server can make incompatible changes.
So signal-cli releases older than three months may not work correctly.

Installation

You can build signal-cli yourself or use
the provided binary files, which should work on Linux, macOS and
Windows. There's also a docker image and some Linux packages provided by the community.

System requirements:

  • at least Java Runtime Environment (JRE) 25

  • native library: libsignal-client

    The native libs are bundled for x86_64 Linux (with recent enough glibc), Windows and MacOS. For other
    systems/architectures
    see: Provide native lib for libsignal

Install system-wide on Linux [ JVM build ]

See latest version.

sh
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//')
curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}".tar.gz
sudo tar xf signal-cli-"${VERSION}".tar.gz -C /opt
sudo ln -sf /opt/signal-cli-"${VERSION}"/bin/signal-cli /usr/local/bin/

Install system-wide on Linux [ GraalVM native build ]

sh
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//')
curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}"-Linux-native.tar.gz
sudo tar xf signal-cli-"${VERSION}"-Linux-native.tar.gz -C /opt
sudo ln -sf /opt/signal-cli /usr/local/bin/

You can find further instructions on the Wiki:

Usage

For a complete usage overview please read
the man page and
the wiki.

Important: The ACCOUNT is your phone number in international format and must include the country calling code. Hence it
should start with a "+" sign. (See Wikipedia for a list
of all country codes.)

  • Link to an existing account

    If you have an existing Signal account associated
    with a number, you can link signal-cli to it with:

    text
    signal-cli link
  • Register a number (with SMS verification)

    Alternatively, if you don't have an existing Signal
    account, you can register one from signal-cli. Note
    that this will unregister any existing client
    associated with the same number.

    text
    signal-cli -a ACCOUNT register

    You can register Signal using a landline number. In this case, you need to follow the procedure below:

    • Attempt a SMS verification process first (signal-cli -a ACCOUNT register)

      • You will get an error 400 (InvalidTransportModeException), this is normal
    • Wait 60 seconds

    • Attempt a voice call verification by adding the --voice switch and wait for the call:

      sh
      signal-cli -a ACCOUNT register --voice

    Registering may require solving a CAPTCHA
    challenge: Registration with captcha

  • Verify the number using the code received via SMS or voice, optionally add --pin PIN_CODE if you've added a pin code
    to your account

    text
    signal-cli -a ACCOUNT verify CODE
  • Send a message

    sh
    signal-cli -a ACCOUNT send -m "This is a message" RECIPIENT
  • Send a message to a username, usernames need to be prefixed with u:

    sh
    signal-cli -a ACCOUNT send -m "This is a message" u:USERNAME.000
  • Pipe the message content from another process.

    text
    uname -a | signal-cli -a ACCOUNT send --message-from-stdin RECIPIENT
  • Receive messages

    text
    signal-cli -a ACCOUNT receive

Hint: The Signal protocol expects that incoming messages are regularly received (using daemon or receive
command). This is required for the encryption to work efficiently and for getting updates to groups, expiration timer
and other features.

Storage

The password and cryptographic keys are created when registering and stored in the current users home directory:

text
$XDG_DATA_HOME/signal-cli/data/
$HOME/.local/share/signal-cli/data/

Building

This project uses Gradle for building and maintaining dependencies. If you have a recent gradle
version installed, you can replace ./gradlew with gradle in the following steps.

  1. Checkout the source somewhere on your filesystem with

    text
    git clone https://github.com/AsamK/signal-cli.git
  2. Execute Gradle:

    text
    ./gradlew build

    2a. Create shell wrapper in build/install/signal-cli/bin:

    text
    ./gradlew installDist

    2b. Create tar file in build/distributions:

    text
    ./gradlew distTar

    2c. Create a fat tar file in build/libs/signal-cli-fat:

    text
    ./gradlew fatJar

    2d. Compile and run signal-cli:

    sh
    ./gradlew run --args="--help"

JSON Schemas for the JSON-RPC mode

  1. Generate JSON Schema files for all the JSON-RPC data classes (src/main/java/org/asamk/signal/json):

    sh
    ./gradlew jsonSchemas
  2. The generated files can be found in the build/generated/META-INF/schemas folder.

Building a native binary with GraalVM (EXPERIMENTAL)

It is possible to build a native binary with GraalVM. This is still experimental and will not
work in all situations.

  1. Install GraalVM and setup the environment

  2. Execute Gradle:

    text
    ./gradlew nativeCompile

    The binary is available at build/native/nativeCompile/signal-cli

FAQ and Troubleshooting

For frequently asked questions and issues have a look at the wiki.

License

This project uses libsignal-service-java from Open Whisper Systems:

https://github.com/WhisperSystems/libsignal-service-java

Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html


File: docs/CALL_TUNNEL.md

Voice Call Support

Overview

signal-cli supports voice calls by spawning a subprocess called
signal-call-tunnel for each call. The tunnel handles WebRTC negotiation and
audio transport. signal-cli communicates with the tunnel over its stdin/stdout
using newline-delimited JSON messages, relaying signaling between the tunnel
and the Signal protocol.

text
signal-cli                        signal-call-tunnel
    |                                     |
    |-- spawn --------------------------->|
    |-- config JSON on stdin ------------>|
    |                                     |
    |-- commands on stdin --------------->|
    |<-- events on stdout ----------------|
    |                                     |   WebRTC
    |   signaling relay                   |   audio I/O
    |                                     |
    |   (stderr: tunnel logging) -------->| (captured by signal-cli)

Each call gets its own tunnel process. When the call ends, signal-cli closes
stdin and destroys the process.

Audio device names (inputDeviceName, outputDeviceName) are opaque strings
returned by the tunnel in its ready message. signal-cli passes them through
to JSON-RPC clients, which use them to connect audio via platform APIs.


Spawning the Tunnel

For each call, signal-cli:

  1. Spawns signal-call-tunnel
  2. Writes config JSON followed by a newline to stdin
  3. Keeps stdin open for subsequent control messages
  4. Reads control events from stdout
  5. Captures stderr for logging

The signal-call-tunnel binary is located by searching (in order):

  1. SIGNAL_CALL_TUNNEL_BIN environment variable
  2. <signal-cli install dir>/bin/signal-call-tunnel (detected from jar location)
  3. signal-call-tunnel on PATH

Config JSON

The first line written to the tunnel's stdin:

json
{
  "call_id": 12345,
  "is_outgoing": true,
  "local_device_id": 1,
  "input_device_name": "signal_input",
  "output_device_name": "signal_output"
}
Field Type Description
call_id unsigned 64-bit integer Call identifier (use unsigned representation)
is_outgoing boolean Whether this is an outgoing call
local_device_id integer Signal device ID
input_device_name string (optional) Requested input audio device name
output_device_name string (optional) Requested output audio device name

If input_device_name or output_device_name are omitted, the tunnel
chooses default names. On Linux, these are per-call unique names (e.g.,
signal_input_<call_id>). On macOS, these are the fixed names signal_input
and signal_output, which must match the pre-installed BlackHole drivers.


Control Protocol

Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout
(tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent
lines are control messages.

signal-cli -> Tunnel (stdin)

Type When Fields
createOutgoingCall Outgoing call setup callId, peerId
proceed After offer/receivedOffer callId, hideIp, iceServers
receivedOffer Incoming call callId, peerId, opaque, age, senderDeviceId, senderIdentityKey, receiverIdentityKey
receivedAnswer Outgoing call answered opaque, senderDeviceId, senderIdentityKey, receiverIdentityKey
receivedIce ICE candidates arrive candidates (array of base64 opaque blobs)
accept User accepts incoming call (none)
hangup End the call (none)

Tunnel -> signal-cli (stdout)

Type When Fields
ready Control socket bound, audio devices created inputDeviceName, outputDeviceName
sendOffer Tunnel generated an offer callId, opaque, callMediaType
sendAnswer Tunnel generated an answer callId, opaque
sendIce ICE candidates gathered callId, candidates (array of {"opaque":"..."})
sendHangup Tunnel wants to hang up callId, hangupType
sendBusy Line is busy callId
stateChange Call state transition state, reason (optional)
error Something went wrong message

Opaque blobs and identity keys are base64-encoded. ICE servers use the format:

json
{
  "urls": [
    "turn:example.com"
  ],
  "username": "u",
  "password": "p"
}

Startup Sequence

text
signal-cli                          signal-call-tunnel
    |                                       |
    |-- spawn process ------------------>   |
    |-- config JSON + newline on stdin ---->|
    |                                       | parse config
    |                                       | initialize audio
    |                                       |
    |<-------- ready (on stdout) -----------|
    |   {"type":"ready",                    |
    |    "inputDeviceName":"...",           |
    |    "outputDeviceName":"..."}          |
    |                                       |
    |-- control messages on stdin --------->|
    |<-- control events on stdout ----------|

Call Flows

Outgoing call

text
signal-cli            signal-call-tunnel           Remote Phone
  |                          |                          |
  |-- spawn + config ------->|                          |
  |<-- ready ----------------|                          |
  |-- createOutgoingCall --->|                          |
  |-- proceed (TURN) ------->|                          |
  |                          | create offer             |
  |<-- sendOffer ------------|                          |
  |-- offer via Signal -------------------------------->|
  |<-- answer via Signal -------------------------------|
  |-- receivedAnswer ------->| (+ identity keys)        |
  |<-- sendIce --------------|                          |
  |-- ICE via Signal ------------------------------->   |
  |<-- ICE via Signal --------------------------------  |
  |-- receivedIce ---------->|                          |
  |                          | ICE connects             |
  |<-- stateChange:Connected |                          |

Incoming call

text
signal-cli            signal-call-tunnel           Remote Phone
  |                          |                          |
  |<-- offer via Signal --------------------------------|
  |-- spawn + config ------->|                          |
  |<-- ready ----------------|                          |
  |-- receivedOffer -------->| (+ identity keys)        |
  |-- proceed (TURN) ------->|                          |
  |                          | process offer            |
  |<-- sendAnswer -----------|                          |
  |-- answer via Signal -------------------------------->|
  |<-- sendIce --------------|                          |
  |-- ICE via Signal ------------------------------>    |
  |<-- ICE via Signal --------------------------------  |
  |-- receivedIce ---------->|                          |
  |                          | ICE connecting...         |
  |                          |                          |
  |   (user accepts call)    |                          |
  |   Java defers accept     |                          |
  |                          |                          |
  |<-- stateChange:Ringing --|  (tunnel ready to accept)|
  |-- accept --------------->|  (deferred accept sent)  |
  |                          | accept                   |
  |<-- stateChange:Connected |                          |

JSON-RPC client perspective

An external application (bot, UI, test script) interacts via JSON-RPC only.

Important: Call event notifications are not sent by default. Clients must
call subscribeCallEvents before initiating or receiving calls. Without this,
incoming calls are silently ignored (no tunnel is spawned).

text
JSON-RPC Client                    signal-cli daemon
  |                                      |
  |-- subscribeCallEvents() ------------>|  (required: enables call support)
  |                                      |
  |-- startCall(recipient) ------------->|
  |<-- {callId, state,                  -|
  |     inputDeviceName,                 |
  |     outputDeviceName}                |
  |                                      |
  |<-- callEvent: RINGING_OUTGOING ------|
  |   ... remote answers ...             |
  |<-- callEvent: CONNECTED -------------|
  |                                      |
  |   connect to audio devices           |
  |   (via platform audio APIs)          |
  |                                      |
  |-- hangupCall(callId) --------------->|  (or: receive callEvent ENDED)
  |<-- callEvent: ENDED -----------------|
  |   disconnect from audio devices      |

For incoming calls:

text
JSON-RPC Client                    signal-cli daemon
  |                                      |
  |-- subscribeCallEvents() ------------>|  (if not already subscribed)
  |                                      |
  |<-- callEvent: RINGING_INCOMING ------|  (includes callId, device names)
  |                                      |
  |-- acceptCall(callId) --------------->|
  |<-- {callId, state,                  -|
  |     inputDeviceName,                 |
  |     outputDeviceName}                |
  |                                      |
  |<-- callEvent: CONNECTING ------------|
  |<-- callEvent: CONNECTED -------------|
  |                                      |
  |   connect to audio devices           |
  |   (via platform audio APIs)          |

To stop receiving call events, call unsubscribeCallEvents.


State Machine

Call states as seen by JSON-RPC clients:

text
startCall()
                     |
                     v
    +----- RINGING_OUTGOING ----+      RINGING_INCOMING -----+
    |              |            |              |              |
    | (timeout     | (answered) | (rejected)   | acceptCall() | (timeout
    |  ~60s)       |            |              |              |  ~60s)
    v              v            v              v              v
  ENDED        CONNECTED      ENDED       CONNECTING        ENDED
                 |                           |
                 |                           v
                 |                       CONNECTED
                 |                           |
                 | (hangup/error)            | (hangup/error)
                 v                           v
               ENDED                       ENDED

For outgoing calls, CONNECTED fires directly when the tunnel reports
Connected state -- there is no intermediate CONNECTING event.

For incoming calls, CONNECTING is set by Java when the user calls
acceptCall(), before the tunnel completes ICE negotiation.

Both directions have a 60-second ring timeout.

Reconnection (ICE restart):

text
CONNECTED --> RECONNECTING --> CONNECTED  (ICE restart succeeded)
                     |
                     v
                   ENDED  (ICE restart failed)

RECONNECTING maps from the tunnel's Connecting state, which is emitted
during ICE restarts (not during initial connection).


CallManager.java

lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java

Manages the call lifecycle from the Java side:

  1. Spawns signal-call-tunnel and writes config JSON to stdin
  2. Keeps stdin open as the control write channel; reads stdout for control events
  3. Captures stderr for tunnel logging
  4. Parses inputDeviceName and outputDeviceName from the tunnel's ready
    message and includes them in CallInfo
  5. Translates tunnel state changes into CallInfo.State values and fires
    callEvent JSON-RPC notifications to connected clients
  6. Defers the accept message for incoming calls until the tunnel reports
    Ringing state (sending earlier causes the tunnel to drop it)
  7. Schedules a 60-second ring timeout for both incoming and outgoing calls
  8. On hangup: sends hangup message, closes stdin, and destroys the process

Implementation Notes

Peer ID consistency

The peerId field in createOutgoingCall and receivedOffer must be the actual
remote peer UUID (e.g., senderAddress.toString()). The tunnel rejects ICE
candidates if the peer ID doesn't match across calls, causing "Ignoring
peer-reflexive ICE candidate because the ufrag is unknown."

sendHangup semantics

sendHangup from the tunnel is a request to send a hangup message via Signal
protocol. It is not a local state change -- local state transitions come
exclusively from stateChange events. For single-device clients, ignore
AcceptedOnAnotherDevice, DeclinedOnAnotherDevice, and
BusyOnAnotherDevice hangup types in the hangupType field -- sending these to
the remote peer causes it to terminate the call prematurely.

Call ID serialization

Call IDs can exceed Long.MAX_VALUE in Java. Use Long.toUnsignedString() when
serializing to JSON for the tunnel (which expects unsigned 64-bit integers). In
the config JSON, call_id should also use unsigned representation.

Incoming hangup filtering

When receiving hangup messages via Signal protocol, only honor NORMAL type
hangups. ACCEPTED, DECLINED, and BUSY types are multi-device coordination
messages and should be ignored by single-device clients.

JSON-RPC call ID types

JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger,
Integer). Use Number.longValue() rather than direct casting when extracting
call IDs from JSON-RPC parameters.

Identity key format

Identity keys in senderIdentityKey and receiverIdentityKey must be raw
32-byte Curve25519 public keys
(without the 0x05 DJB type prefix). If the
33-byte serialized form is used instead, SRTP key derivation produces different
keys on each side, causing authentication failures.


File: reproducible-builds/README.md

Reproducible builds

This process lets you verify that the version of signal-cli that was downloaded from the Github Releases matches the source code in the public repository.

This is achieved by replicating the build environment as Docker images.

Currently, only the following binaries are reproducible:

  • JAR package (signal-cli-XXX.tar.gz)
  • Native binary (signal-cli-XXX-Linux-native.tar.gz)
  • Rust client binary (signal-cli-XXX-Linux-client.tar.gz)

In the following section, we will use signal-cli version 0.14.2 as the reference example. Simply replace all occurrences of 0.14.2 with the version number you are about to verify.

Step-by-step instructions

0. Prerequisites

Before you begin, ensure you have the following installed:

  • git
  • docker (or podman)

1. Verifying reproducibility

bash
git clone --depth 1 --branch v0.14.2 https://github.com/AsamK/signal-cli
cd ./signal-cli
./reproducible-builds/verify.sh

If each one ends with ... matches! for every binary (except the native one for now), you're good to go! You've successfully verified that the Github Release binaries were built from exactly the same code as is in the signal-cli git repository.

If you get ... doesn't match!, it means something went wrong (except for the native one for now). Please open an issue.