A custom Home Assistant integration that sends SMS, places phone calls, and hears back from the gateway when messages and calls arrive — through the gsmnode API Server.
Home Assistant ──► API Server (/api/messages, /api/calls) ──► your phone ──► SMS / call
Home Assistant ◄── API Server (webhooks) ◄── your phone ◄── SMS / call
Like every other client, it talks only to the API Server (never PocketBase).
There is no YAML. Every part of this — the connection, the sidebar item,
which events arrive, and every notification target — is set from the Home
Assistant UI, and nothing goes in configuration.yaml.
- UI setup (config flow) — add it under Settings → Devices & Services, and change the URL or password later without losing your entities (Reconfigure).
- Actions —
gsmnode.sendtakes the lot: SMS, MMS or a call, to whichever numbers, from whichever phone and SIM, now or later.gsmnode.send_smsandgsmnode.callremain as short forms. The phone is a picker, not a typed id. - Sensors — a
binary_sensor"API Server" (is the gateway up?) plus one connectivity sensor per registered phone, so an automation can react to the phone that actually sends your texts dropping off. - Incoming events (optional) — tick the gateway events you care about and they arrive on the Home Assistant bus, ready for an Event trigger. The webhook is registered at both ends for you. See below.
- Notification targets (optional) — add as many as you like, each its own notify entity with its own type, phone, SIM and recipients. See below.
- Sidebar item (optional) — a gsmnode entry in Home Assistant's left menu that opens the Web App or the API Server panel in place. See below.
- Branding — the gsmnode mark and wordmark ship in
brand/, so the integration carries its own icon instead of a puzzle piece (Home Assistant 2026.3+; older versions ignore the folder). The two actions get icons on any version throughicons.json.
Needs Home Assistant 2025.3 or newer — notification targets are config subentries, which older versions cannot show.
Until it lands in the HACS default store, add it as a custom repository:
- HACS → ⋮ (top right) → Custom repositories.
- Repository
https://github.com/gsmnode/gsmnode-ha, type Integration → Add. - Search HACS for gsmnode → Download.
- Restart Home Assistant (Settings → System → Restart).
The button above does steps 1–3 for you. Once the integration is in the default store, none of this is needed — it will simply be there to search for.
-
Copy
custom_components/gsmnode/from this repository into your Home Assistant config directory, so it lands at:<config>/custom_components/gsmnode/That is next to your
configuration.yaml. Use the Samba / File editor / SSH add-on, or the mapped volume for Docker. -
Restart Home Assistant (Settings → System → Restart).
-
Settings → Devices & Services → Add Integration.
-
Search for gsmnode.
-
Fill in the form:
- API Server URL — e.g.
http://192.0.2.10:8080(must be reachable from HA) - Email / Password — a gateway user. Make one in the Web App, or on
the API Server host with
node scripts/create-user.mjs ha@local "pass" "Home Assistant" - Default phone (optional) — pin sends/calls to a specific phone, by its device ID
The form validates by logging in; you'll get Invalid auth or Cannot connect if something's wrong.
- API Server URL — e.g.
A gsmnode device appears with an API Server connectivity sensor, and each phone registered to that account gets its own device and sensor beneath it.
If the password changes on the gateway, Home Assistant raises a Reauthentication notification instead of silently failing; if the server moves, use Reconfigure on the integration.
gsmnode.send is one action for all three kinds. Every field below has a picker
in the automation editor — the type is a dropdown, the phone is a device list,
the SIM is a number box:
action: gsmnode.send
data:
type: sms # sms | mms | call
phone_numbers: ["+15551234567"]
message: "Hello from Home Assistant"
device: 4a7f… # optional: which phone, from the device picker
sim_number: 0 # optional: 0-based slot, works for calls too
schedule_at: "2026-07-22 18:30:00" # optional (not for calls)An MMS adds a subject and attachments; a call ignores the message and rings each number in turn:
action: gsmnode.send
data:
type: mms
phone_numbers: ["+15551234567"]
subject: "Front door"
message: "Someone at the door"
attachments: ["/config/www/snapshot.jpg"]action: gsmnode.send
data:
type: call
phone_numbers: ["+15551234567"]
sim_number: 1Attachments are read off disk, so each path has to be under an
allowlist_external_dirs directory, and each file at most 1 MB.
sim_number here is a slot index, not a picker. Action fields come from a
static services.yaml that Home Assistant reads once for every install, so it
cannot list SIMs that differ per gateway and per phone — only a config flow can
do that, which is why notification targets get the real list. To see which slot
is which, open a phone's connectivity sensor: its attributes carry the same
slot, carrier and number the picker uses.
action: gsmnode.send_sms
data:
phone_numbers: ["+15551234567"]
message: "Hello from Home Assistant"
sim_number: 0 # optional (dual-SIM; slots count from 0)action: gsmnode.call
data:
phone_number: "+15551234567"
sim_number: 1 # optionalWith more than one gateway configured, add config_entry_id: to say which one
to use — the automation editor offers a picker for it. device (the picker) and
device_id (the gateway's own id) are interchangeable; device_id wins if both
are given.
# Built in the automation editor; this is only what it looks like underneath.
triggers:
- trigger: state
entity_id: binary_sensor.basement_leak
to: "on"
actions:
- action: gsmnode.send_sms
data:
phone_numbers: ["+15551234567"]
message: "Water leak detected in the basement!"
- action: gsmnode.call
data:
phone_number: "+15551234567"triggers:
- trigger: state
entity_id: binary_sensor.gsmnode_api_server
to: "off"
for: "00:02:00"
actions:
- action: persistent_notification.create
data:
title: "gsmnode"
message: "The API Server is unreachable."The same trigger shape works on a phone's own sensor (its entity id comes from the phone's name) — useful when the API Server is up but the phone that does the sending has stopped routing.
The gateway already has two full overviews — the Web App and the API Server's own panel — so the integration does not reimplement either. It puts an item in Home Assistant's sidebar that opens the one you choose, in place, without leaving Home Assistant.
Set it up under Settings → Devices & Services → gsmnode → Configure → Sidebar item:
| Overview to show | Opens |
|---|---|
| No sidebar item | nothing — the default |
| Web App | the full gateway UI: messages, inbox, devices, webhooks, settings |
| API Server panel | status and administration: users, plugins, connected devices |
| Another address | any URL you give it |
The address is worked out for you: the API Server panel is the URL you signed in
against, and the Web App's is whatever the API Server reports for it on
/api/status — both shown in the form. Fill the Address field in only when
that is not the address your browser needs. The panel is loaded by the
browser, not by Home Assistant, so a container name or a localhost that means
something different on the two machines has to be overridden here.
Sidebar title names the item — worth setting if you run more than one gateway — and Administrators only hides it from non-admin users. Changing any of this reloads the integration and the sidebar updates immediately.
Two limits worth knowing before you wonder why a page is blank:
- A page served over http cannot be embedded in a Home Assistant served over https. Browsers block the mixed content and Home Assistant shows an error in place of the frame. Put the gateway behind the same kind of TLS Home Assistant uses, or open it in its own tab.
- The embedded app keeps its own login. Signing in to Home Assistant does not sign you in to the Web App; that session lives in the frame and persists there like it would in a tab.
Sending is only half a gateway. Configure → Incoming events ticks what the gateway should tell Home Assistant about:
| Event | Fires as | Carries |
|---|---|---|
sms:received |
gsmnode_sms_received |
phone_number, message, received_at, sim_slot, encrypted |
sms:sent · sms:delivered · sms:failed |
gsmnode_sms_sent … |
message_id, phone_numbers, status, error |
sms:data-received |
gsmnode_sms_data_received |
the above plus data_payload, data_port |
mms:received · mms:downloaded |
gsmnode_mms_received … |
the above plus subject, attachments |
call:received · call:sent · call:failed |
gsmnode_call_received … |
call_id, phone_number, direction, status, started_at, duration |
Nothing has to be registered by hand at either end. Home Assistant mints a webhook of its own when the entry is created, and the integration subscribes that URL with the API Server for exactly the ticked events — unticking removes the subscription again, and deleting the integration unsubscribes it entirely. Only subscriptions carrying this Home Assistant's webhook id are touched, so anything you registered yourself in the Web App is left alone.
To use one, build an automation (Settings → Automations → Create), choose
the Event trigger, and give it the event type from the table — the options
form prints the exact names your current selection produces. Every event also
carries device_id and created_at, and the payload is flattened to the top
level, so a template reads trigger.event.data.phone_number directly:
# What the UI editor produces — you never have to write this by hand.
triggers:
- trigger: event
event_type: gsmnode_sms_received
actions:
- action: persistent_notification.create
data:
title: "SMS from {{ trigger.event.data.phone_number }}"
message: "{{ trigger.event.data.message }}"Two things to know:
- The gateway has to be able to reach Home Assistant. The address used is the one Home Assistant knows itself by (internal first, external as a fallback); if the gateway needs a different one, set it in the same form.
- With end-to-end encryption on,
phone_numberandmessagearrive asgsmenc:v1:…ciphertext. Home Assistant has no passphrase and cannot read them — this integration does not do E2E.
A notify entity is called with a message and nothing else — notify.send_message
has no field for a recipient, a phone or a SIM. So each notification target
decides all of that up front, and you add one per combination you need. They
appear on the integration's page under Add notification target:
| Field | What it does |
|---|---|
| Name | names the entity — "Alerts" gives notify.alerts |
| Send as | SMS, MMS, or a call that rings the recipients |
| Recipients | every message to this target goes to all of them |
| Phone | which gateway phone sends it, from a picker |
| SIM | picked from the phone's actual SIMs — carrier and number, not a slot index |
| MMS subject | used when nothing passes a title with the message |
action: notify.send_message
target:
entity_id: notify.alerts
data:
message: "Washing machine finished"The SIM is a second step, because what it can offer depends on the phone chosen in the first. The phones report their own SIMs on every heartbeat — slot, carrier, number — so the list reads SIM 1 (slot 0) — Orange · +48555111222 rather than asking for a slot index. Leave it empty for the phone's default SIM.
Two things make that list empty, and both fall back to typing a slot number: a phone that has not been granted the phone permission reports no SIMs at all, and a gateway that is unreachable has nothing to report. With no phone chosen, every phone's SIMs are offered, collapsed by slot — the stored value is the slot, so two phones' "SIM 1" is one choice that names both.
Each target gets its own device and entity, so an alert can text the family from
one SIM while an escalation rings the on-call phone from the other. Editing a
target — or deleting it — is on the same page. An MMS target accepts a title:
and sends it as the subject.
For anything that has to choose per message, use gsmnode.send, which takes all
the same fields as arguments.
Replaced the single
notify.gsmnodeentity of 3.0.0, and the YAML platform before it. If you had either, add a target here instead.
- On first use it logs in (
POST /api/auth/login) and caches the token; on a401it re-logs in once and retries, and gives up into a reauth prompt if that fails too. send_sms→POST /api/messages;call→POST /api/calls.- Every 30s one coordinator polls
GET /api/healthandGET /api/devicesand feeds both the gateway sensor and the per-phone ones. An unreachable server turns the sensor off rather than making it unavailable — reporting that is the sensor's whole job. - All HTTP uses Home Assistant's shared aiohttp session (fully async), with a 15s timeout per request.
- The sidebar item is an iframe panel (
frontend.async_register_built_in_panel) registered when the entry loads and removed when it unloads. Nothing is proxied through Home Assistant — the browser fetches the page straight from the gateway. gsmnode.sendposts to/api/messagesfor an SMS or MMS and/api/callsfor a call; the SIM slot is 0-based on the wire in both. Choosing a SIM for a call needs an API Server and Phone Agent from this repo at or after the same commit — older ones accept the field and ignore it.- MMS attachments are read in an executor, checked against
allowlist_external_dirs, capped at 1 MB each, and base64'd into the request. - The SIM list is built from
GET /api/devices, which the coordinator already polls. The logic lives insims.py, kept free of Home Assistant imports sotests/test_sims.pycan exercise it with plain Python:python -m unittest discover tests. - Incoming events use Home Assistant's own webhook component. The id is minted
once per entry and stored with it, the URL is
GET-proof (POSTonly), and each delivery is re-fired on the bus. Subscriptions on the gateway are reconciled on every load, so the set on the server always matches the ticks in the form.
Worth understanding before this carries anything that matters.
Use https if the gateway is not on a network you trust. Over plain http the login sends your gateway password in the request body, the token that follows rides in a header, and message text and recipient numbers are readable to anything on the path. The client uses Home Assistant's shared session, which validates certificates — so https gives real protection, and a self-signed certificate will be refused with no override. The API Server speaks http only, so https means putting a reverse proxy in front of it.
Incoming deliveries are signed. The webhook URL alone is a weak credential:
the gateway knows it, stores it, and anything that learns it could otherwise
post a forged sms:received that automations would act on. So the integration
mints a secret per entry, hands it to the gateway at registration, and the
gateway signs every delivery:
X-GsmNode-Timestamp: 1700000000
X-GsmNode-Signature: sha256=<hmac-sha256 of "<timestamp>.<body>">
Anything unsigned, wrongly signed, or more than five minutes old is answered
401 and never reaches the bus. The timestamp is inside the MAC, so a captured
delivery cannot be replayed by rewriting its header. When the gateway reaches
Home Assistant on the local network, the webhook is additionally registered
local_only, so a forged request from outside is refused before any of this.
What is still worth knowing:
- The gateway password is stored in the config entry, which Home Assistant
writes unencrypted to
.storage/core.config_entries. Filesystem or backup access to the Home Assistant host gives up that password. The session token itself is only ever held in memory. - Messages sent from here are not end-to-end encrypted, so the API Server and its database can read them, unlike ones composed in the Web App.
- The sidebar panel loads the gateway straight into your browser, so http there is your browser's plaintext, and the gateway login is typed into that frame.
brand/ holds what Home Assistant shows for the integration itself, in the
layout and sizes its brands repository
requires — a local brand/ folder wins over the CDN, so nothing has to be
submitted upstream:
| File | Source (design kit) | Size |
|---|---|---|
icon.png |
app-icon-512.png, scaled |
256×256 |
icon@2x.png |
app-icon-512.png |
512×512 |
logo.png |
lockup-horizontal-color.png |
936×224 |
dark_logo.png |
lockup-horizontal-white.png |
936×224 |
The icon is the app tile — signal-green with white arrows — which stays legible
on either theme, so no dark_icon.png is needed. The wordmark does need both:
logo.png sets "gsm" in ink, dark_logo.png in white. There is no
logo@2x.png; the widest lockup in the kit is 936×224, and its short side would
have to be upscaled to reach the hDPI range.
- The API Server must be reachable from the Home Assistant host.
- No external dependencies (
requirements: []). - SIM slots are 0-based here and everywhere else in gsmnode: slot
0is the first SIM. (A phone's sensor lists its slots and carriers in its attributes.) - Only plain SMS and calls are wired up. The API Server also accepts MMS
(
type: mmswith subject/attachments) and data SMS (type: data); those have no service here yet. - End-to-end encryption is not supported. Messages sent from here are plaintext and flagged as such, so a Phone Agent with a passphrase still sends them fine — but the API Server can read them, unlike ones composed in the Web App. Incoming SMS relayed to a HA webhook likewise arrive as ciphertext if the Phone Agent encrypted them.
The integration is one surface of gsmnode, which also has an API Server, a
Web App, and two Android apps — a Phone Agent that does the sending and a Phone
App that manages the gateway. Those live in the project's own repository,
freefall-dev/gsmnode; this one holds
the Home Assistant integration alone, because HACS requires
custom_components/<domain>/ at the root of the repository it installs from.
It is published by splitting that folder out, so the history here is the real history of the integration and not a squash. Issues and pull requests are welcome here; changes are merged upstream and land back on the next release.
The gsmnode name and marks are excluded from that grant — see NOTICE and section 6 of the License. Fork it freely; just don't ship the fork under this name and logo.