MorseHub

API keys

Read and send your MorseHub texts from your own scripts and programs.

Updated October 15, 2026

What an API key is

An API key is one more paired device on your account, like a computer you added. It holds the key that decrypts your texts.

Your texts are encrypted on your phone before they leave it and are not decrypted until they reach a device you paired. MorseHub's server cannot read them, so there is no plain web API to call. You install a small library instead. It keeps the key on your computer and decrypts there. Your code calls it to list conversations, read texts and send texts.

Install

It needs Node.js 22 or newer. On Node 20 it also needs the ws package: npm install ws in your project, or npm install -g ws for the morsehub command.

The library is not on npm, and a package there named morsehub would not be ours. Install it only from the file on morsehub.net.

Download the file and check its SHA-256 before you install it. On a Mac or Linux:

curl -O https://morsehub.net/sdk/morsehub-0.2.3.tgz
curl -O https://morsehub.net/sdk/morsehub-0.2.3.tgz.sha256
sha256sum -c morsehub-0.2.3.tgz.sha256

It should print morsehub-0.2.3.tgz: OK. On a Mac without sha256sum, run shasum -a 256 -c morsehub-0.2.3.tgz.sha256 instead.

On Windows, in PowerShell:

curl.exe -O https://morsehub.net/sdk/morsehub-0.2.3.tgz
(Get-FileHash morsehub-0.2.3.tgz).Hash.ToLower()

It should print exactly 9416bec285f0284f11ddfbdf87946f17985baf4567cbed2a766cea21a5400ece. MorseHub shows the same hash when you make a key. If it does not match, do not install it, and email support@morsehub.net.

Then install it. To get the morsehub command, install it globally:

npm install -g ./morsehub-0.2.3.tgz

For your own code, install it in your project's folder. Load it with import; it does not support require.

npm install ./morsehub-0.2.3.tgz

Pair a key

  1. Open MorseHub on a computer and go to Settings, API keys. Click New API key.
  2. Give it a name, like "Home Assistant", and pick its access: Read only or Read and send. Click Make key.
  3. Copy the key. MorseHub shows it only once. It can be used one time, within 10 minutes.
  4. On the computer that will use it, run this:
morsehub pair --name "Home Assistant" --host https://morsehub.net
  1. Paste the key when it asks and press Enter. Nothing shows as you paste. The key never goes on the command line, so it stays out of your shell history.

--name is required, but it only labels the key on that computer. MorseHub lists the key under the name you gave it in Settings.

The command MorseHub shows after you make a key does the same thing without installing first. It starts with npx --yes https://morsehub.net/sdk/morsehub-0.2.3.tgz and fetches the same file.

Pairing writes a state file, morsehub-state.json, in the folder you ran it from. Add --state <file> to put it somewhere else. The address from --host is saved in that file, so later commands do not need it.

The state file holds the token and the keys that decrypt your texts. Whoever has it can read them, and with a Read and send key, send texts from your number. Keep it private. Never commit it to git or share it. If it leaks, remove the key in Settings, API keys. It stops working at once, but texts already saved in the copy stay readable to whoever has it.

Read your texts

Save this as read.mjs in your project's folder, next to the state file. Run it with node read.mjs.

import { MorseHub } from 'morsehub';

const mh = await MorseHub.open({
  state: './morsehub-state.json',
});
const threads = mh.threads();
for (const t of threads.slice(0, 5)) {
  const who = t.name ?? t.members[0].number;
  console.log(t.id, who, t.unread, t.last.text);
}
if (threads.length) {
  const id = threads[0].id;
  for (const m of mh.messages(id, { limit: 20 })) {
    const who = m.fromName ?? m.from ?? 'me';
    console.log(m.dir, who, m.text);
  }
}
await mh.close();

A conversation's id is the person's number as digits, like 2025550142. A group's id starts with g:. You can pass a number in any format.

Pictures, videos, voice notes and contact cards are in each message's parts. await mh.part(part.ref) returns the bytes. If the file is not on MorseHub's server, the library asks your phone for it and waits up to 60 seconds. A part whose ref is null cannot be loaded.

Send a text

This needs a Read and send key.

import { MorseHub } from 'morsehub';

const mh = await MorseHub.open({
  state: './morsehub-state.json',
});
const r = await mh.send({
  to: '+12025550142',
  text: 'Running 10 minutes late',
});
console.log(r.state);
await mh.close();

Several numbers make a group text. Pictures go in files:

await mh.send({
  to: ['+12025550142', '+12025550117'],
  text: 'Here it is',
  files: [{ path: './photo.jpg' }],
});

send waits up to 30 seconds for your phone to take the text, then up to about a minute in all for the phone to say it went out. If the phone does not take it in time, the library asks MorseHub to withdraw it, so it does not go out hours later by surprise. state is one of these:

sent
The phone sent it.
failed
The phone tried and could not, or MorseHub refused it, for example when the account's storage is full. err has the phone's reason when it gave one.
acked
The phone has it and is sending it. Do not send it again.
unconfirmed
The phone did not answer in time, but it may have received the text and sent it. Do not send it again. withdrawn is false when this computer could not reach MorseHub to withdraw it. Then it may still go out when the phone next connects, so keep your program running: the library withdraws it when it reconnects. messages() shows what happened: a status of sent or delivered means it went out, and cancelled (once the phone is back online) means it did not.
phone_unreachable
Not sent, and it will not go out later. reason is offline (no phone was connected while it waited) or relay (this computer could not reach MorseHub).
cap
Over the send limit. Nothing was sent.
scope
The key is read only. Nothing was sent.
phone_old
Group texts and pictures need a newer MorseHub app on the phone.

To change how long send waits for the phone, pass ackWait in milliseconds, from 3,000 to 120,000.

Live events

import { MorseHub } from 'morsehub';

const mh = await MorseHub.open({
  state: './morsehub-state.json',
});
mh.on('message', (m) => {
  if (m.dir !== 'in') return;
  const who = m.fromName ?? m.from;
  console.log('New text from', who, m.text);
});
mh.on('call', (c) => {
  console.log('Call from', c.name ?? c.from);
});
mh.on('phone', (p) => {
  console.log('Phone online:', p.online);
});
mh.on('revoked', () => process.exit(1));

The script keeps running until you call mh.close() or stop it. The events are message, call, status (what happened to a text you sent), phone (online or offline), banner (a notice from MorseHub) and revoked (the key was removed or the account was deleted).

Command line

morsehub pair --name <name>
Redeem a key. It asks for the key.
morsehub threads [--limit n]
Conversations, newest first. 50 unless you give --limit.
morsehub read <thread> [--limit n]
One conversation, by id, number or contact name. The last 30 texts unless you give --limit.
morsehub send <to> <text> [--file path] [--wait s]
Send a text. <to> is a number; names work only with read. Numbers joined by commas make a group text. --file adds a picture; repeat it for more. --wait is how many seconds to wait for the phone, 3 to 120 (30 by default).
morsehub tail
Live events as JSON lines, until you press Ctrl+C.

Every command takes --state <file> (otherwise $MORSEHUB_STATE, then ./morsehub-state.json), --host <url> (https only) and --json for output a program can read. On commands other than pair, --host applies to that run only.

send exits 0 when the text was sent or the phone has it, 2 when it was not sent (it says why), and 3 when it is not confirmed. On 3 it may have gone out, so check with morsehub read before you send it again. Usage mistakes, like a missing argument, exit 64. Other errors exit 1.

The command line prints control characters and text direction marks in a text as their code point, like <U+202E> (or \u202e in --json output), so a text someone sends you cannot move your cursor or hide words.

Library reference

MorseHub.open({ state, host?, stateKey? })
Opens a paired state file and catches up. Throws offline if it cannot reach MorseHub within 20 seconds.
MorseHub.pair({ code, name, state, host?, stateKey? })
Redeems a key and writes the state file. morsehub pair does this for you. It will not overwrite a working state file.
mh.threads()
Conversations, newest first: { id, members, group, name, last, unread }.
mh.messages(id, { limit?, before? })
One conversation, oldest first: the newest 50 unless you give limit (up to 1,000). before is a time in milliseconds or a message id, for paging back.
mh.search(query, { limit? })
Texts that contain that exact text, in any case, newest first. 20 unless you give limit (up to 500).
mh.contacts()
Names and numbers from the phone.
await mh.part(ref)
The bytes of a picture, video, voice note or contact card.
await mh.send({ to, text?, files?, ackWait? })
Sends a text. Returns { id, state }. Read and send keys only.
await mh.markRead(id)
Marks a conversation read on the phone and on every computer. Read and send keys only.
mh.phone()
{ online, seen, mms, app }. mms is whether the phone can send group texts and pictures.
mh.scope
read or send.
mh.limits
Your account's send limits and picture size: { hour, day, blobMax, blobDays }.
mh.on(event, fn)
See Live events.
await mh.close()
Disconnects and saves the state file.

A message is { id, thread, dir, from, to, text, at, status, parts }, plus fromName and otp. dir is in or out. from is null for your own texts, and fromName is the contact's name on an incoming text when the phone knows it. at is the phone's time in milliseconds. otp is true when a text looks like a login code. The full types are in index.d.ts inside the package.

Key access

Read only
Texts, conversations, contacts, live events and pictures. It can ask the phone for a picture. It cannot send texts or mark them read.
Read and send
All of that, plus sending texts and pictures and marking conversations read.

MorseHub sets the access when you make the key. Changing the library's code cannot change it. Pick Read only unless the script really needs to send.

Login codes come through like any other text, so an AI agent with any key can read your login codes.

Limits

  • 120 texts an hour and 600 a day per account. Texts you send from your computers count too. Over that, send returns cap.
  • One text: up to 5,000 characters, 1 to 20 numbers, and up to 10 files.
  • Files: about 1.5 MB each. JPEG, PNG, GIF, WebP, MP4, 3GP, 3G2, WebM, M4A, AAC, MP3, AMR, OGG, WAV or VCF, going by the file name. Carriers often limit picture texts to less, and the library does not shrink photos, so shrink big ones first.
  • Asking the phone for pictures: 60 times an hour per account.
  • Pictures stay on MorseHub's server for 15 days. Uploads: 300 a day and 100 MB stored at once per account.
  • 5 API keys per account, and 10 computers and API keys in all.
  • 3 keys waiting to be paired at a time. Making a fourth cancels the oldest.

Errors

The library throws a MorseHubError. Its code is one of these:

offline
Could not reach MorseHub. Check the connection and try again. You also get this while the account is paused, for example after the subscription ends.
no_state
No state file at that path, or it could not be read. Pair first.
revoked
The key was removed. Make a new one and pair again.
deleted
The MorseHub account was deleted.
scope
A read-only key tried to mark a conversation read.
key_pending
The phone is sharing a new key. Try again in a minute.
bad_code
That is not a MorseHub API key.
pair_failed
The key expired or was already used. Make a new one.
exists
A working state file is already at that path. Use another path.
devices
Too many devices are paired. Remove one first.
rate
Too many tries. When pairing, wait a minute. For pictures, the account uploaded 300 files in the last 24 hours.
cap
Over a limit, such as 60 picture requests an hour.
timeout
The phone did not answer in time.
phone_unreachable, phone_old
From part(): the phone did not take the request in time, or its MorseHub app is too old to fetch that file.
failed
From part(): MorseHub refused the request. Try again later.
not_found
No such conversation, message or attachment.
gone, expired, not_available
That attachment cannot be loaded: it is no longer on the phone, it is past its 15 days, or the phone did not keep it.
big
A file is over about 1.5 MB, or a file on the phone is too big to fetch.
quota
Picture storage (100 MB) is full for now. Older pictures clear after 15 days.
bad_args
Something in the call is wrong. The message says what.
closed
The client was already closed.

A file path that does not exist, or a state file that cannot be written, throws Node's own error instead, like ENOENT.

Keeping it safe

  • The library decrypts texts in memory only. The state file stores them encrypted, along with the token and the keys.
  • The library writes the state file so only your user account can read it. On Windows, keep it in a folder only you can open.
  • To encrypt the state file itself, pass stateKey (32 random bytes) to pair and open. Keep that key somewhere other than the state file's folder. The morsehub command cannot open an encrypted state file, so this is for your own code only.
  • The library logs nothing. The secret part of the token never goes in a web address.
  • Two programs can share one state file. Each keeps its own connection.

Remove a key

In MorseHub, go to Settings, API keys and click Remove next to the key. It stops working at once, and your phone makes a new message key. The library wipes the state file down to a note that says the key was removed, and fires revoked. If your program is not running then, the next open() wipes the file and throws revoked.

To connect again, make a new key and pair again. Questions: support@morsehub.net.