A how-to for wiring Sendblue into Home Assistant so that any automation can send iMessage/SMS notifications, and inbound texts are answered by Home Assistant Assist — turning a phone number into a conversational interface for your house.
There is no native Home Assistant integration for Sendblue (don't confuse it with SensorBlue or Sendinblue/Brevo). None is needed: Sendblue is a REST API plus webhooks, which maps directly onto rest_command for sending and a webhook trigger for receiving.
Architecture
Outbound: automation → script.notify_sendblue → rest_command.sendblue_send → Sendblue API → phone
Inbound: phone → Sendblue receive webhook → HA /api/webhook/<id>?token=<secret>
→ conditions (token, direction, allowlist, non-empty)
→ conversation.process (Assist)
→ script.notify_sendblue → reply to sender
Files touched: secrets.yaml, configuration.yaml, scripts.yaml, automations.yaml, plus the Sendblue dashboard and two Home Assistant settings pages.
Prerequisites
You need a Sendblue account (dashboard.sendblue.com) with an API key ID and API secret key from the API Keys section, and the phone number of a line assigned to your account — from_number is mandatory on every send and must be one of your lines (GET /api/lines with the auth headers lists them). On the free shared-line plan, a recipient must be added as a contact on your account and must text your Sendblue number once before outbound messages to them will deliver; do this handshake with your own phone before testing, because the API can accept a message (QUEUED) that then silently never arrives. Production use is the paid per-line plan, so validate everything in the sandbox first — Sendblue is US-centric and UK numbers are worth confirming early.
On the Home Assistant side you need an HTTPS-reachable external URL (Cloudflare tunnel, Nabu Casa, or reverse proxy) for the inbound webhook, and Assist configured with a conversation agent.
Step 1 — Secrets
# secrets.yaml
sendblue_api_key_id: "YOUR_KEY_ID"
sendblue_api_secret_key: "YOUR_SECRET_KEY"
Step 2 — The send primitive (rest_command)
# configuration.yaml
rest_command:
sendblue_send:
url: "https://api.sendblue.co/api/send-message"
method: POST
content_type: "application/json"
headers:
sb-api-key-id: !secret sendblue_api_key_id
sb-api-secret-key: !secret sendblue_api_secret_key
payload: >
{
"number": "+{{ number | string | replace('+', '') }}",
"from_number": "{{ from_number }}",
"content": "{{ content }}"
{% if media_url is defined and media_url %},"media_url": "{{ media_url }}"{% endif %}
}
The number line is deliberately defensive: +{{ number | string | replace('+', '') }} accepts an integer or a string, with or without a leading plus, and always emits valid E.164. This matters because Home Assistant's template engine converts rendered results that parse as Python literals into native types, and +447700900123 is a valid numeric literal — so a phone number passed through automation or script variables arrives here as the integer 447700900123 with the plus stripped. Normalising inside the payload is the one place immune to that coercion, because the number is embedded in a larger JSON string that cannot be literal-parsed. See Troubleshooting for the full story.
rest_command is file-based YAML: changes only take effect after Developer Tools → YAML → reload REST commands (or a restart). An edited-but-not-reloaded payload is a classic silent failure.
Step 3 — The notify-style wrapper (script)
This gives you notify.*-style call semantics (message, title, target), a proper form in the automation UI via fields:, and parallel execution so concurrent sends don't queue.
# scripts.yaml
notify_sendblue:
alias: "Notify: Sendblue"
description: "Send an iMessage/SMS via Sendblue"
mode: parallel
max: 10
fields:
message:
name: Message
required: true
selector:
text:
multiline: true
title:
name: Title
required: false
selector:
text:
target:
name: Target
description: E.164 number, defaults to you
required: false
selector:
text:
media_url:
name: Media URL
required: false
selector:
text:
sequence:
- variables:
resolved_target: "{{ target | default('+44XXXXXXXXXX', true) }}"
resolved_content: >-
{{ (title ~ '\n') if title is defined and title else '' }}{{ message }}
- action: rest_command.sendblue_send
data:
number: "{{ resolved_target }}"
from_number: "+1XXXXXXXXXX"
content: "{{ resolved_content }}"
media_url: "{{ media_url | default('') }}"
Replace the default target with your own number and from_number with your actual Sendblue line — a placeholder here is rejected on every send. Note that default: under fields: only prefills the UI form; it is not applied at runtime, which is why defaults are handled again in the variables: step.
Usage from any automation:
actions:
- action: script.notify_sendblue
data:
title: "Unraid"
message: "Parity check finished — 0 errors"
If you specifically need a real notify.sendblue service (the main reason being the alert integration, whose notifiers: only accepts notify services), the legacy REST notify platform can produce one, but it drops title, only uses the first target, and has no per-message media_url. For everything else the script is the better citizen.
Step 4 — Prepare Assist
Two decisions before wiring the inbound leg. First, the agent: conversation.home_assistant is the built-in agent — fast, local, good for commands and simple questions, rigid for conversation. For a chattier experience, add an LLM conversation integration (Anthropic, OpenAI, Gemini, Ollama), enable its Control Home Assistant / Assist option so it can act on devices, and note the entity ID from the agent_id dropdown in Developer Tools → Actions.
Second, exposure: Settings → Voice assistants → Expose. This is the blast-radius control for the entire feature — anything exposed is textable by anyone who passes the allowlist. Lights, media, and sensors are reasonable; leave locks, garage doors, and alarm panels unexposed.
Step 5 — The inbound bridge (automation)
Generate a long random webhook ID and a separate shared token (openssl rand -hex 16 for each). Your endpoint will be:
https://ha.yourdomain.com/api/webhook/<WEBHOOK_ID>?token=<SHARED_TOKEN>
# automations.yaml
- alias: "Sendblue → Assist bridge"
mode: parallel
max: 10
triggers:
- trigger: webhook
webhook_id: "<WEBHOOK_ID>"
allowed_methods: [POST]
local_only: false
variables:
allowed_senders:
- "+44XXXXXXXXXX"
conditions:
- condition: template
value_template: "{{ trigger.query.token | default('') == '<SHARED_TOKEN>' }}"
- condition: template
value_template: "{{ trigger.json.is_outbound == false }}"
- condition: template
value_template: "{{ trigger.json.from_number in allowed_senders }}"
- condition: template
value_template: "{{ (trigger.json.content | default('') | trim | length) > 0 }}"
actions:
- variables:
sender: "{{ trigger.json.from_number }}"
incoming: "{{ trigger.json.content | trim }}"
- action: conversation.process
data:
text: "{{ incoming }}"
agent_id: conversation.home_assistant
language: en
conversation_id: "sendblue-{{ sender | replace('+', '') }}"
response_variable: assist
- action: script.notify_sendblue
data:
target: "{{ sender }}"
message: "{{ assist.response.speech.plain.speech | default('Done.', true) }}"
Design notes, each earned the hard way. The four conditions are separate rather than one combined template so that every trace shows a per-clause true/false — a single monolithic condition reports only "condition/0: false" and turns debugging into archaeology. The empty-content guard exists because tapbacks, read receipts, and media-only messages hit the receive webhook with no text and would otherwise become nonsense Assist queries. The per-sender conversation_id gives each person their own thread: providing the same ID continues that conversation, so follow-up questions keep context with an LLM agent (short-term only — agent chat history expires after inactivity). mode: parallel stops two senders serialising behind each other's LLM latency. And timing is a non-issue by construction: HA acks the webhook with a 200 immediately and runs actions asynchronously, so slow agents never trip Sendblue's 45-second retry window.
Step 6 — Point Sendblue at Home Assistant
In the Sendblue dashboard, paste the full endpoint URL — including the ?token= query string — as the receive webhook. Sendblue expects a 200 to acknowledge delivery and avoid duplicate calls; HA's webhook trigger returns that automatically.
Step 7 — Test, in fault-isolating order
Test the send primitive raw from Developer Tools → Actions with rest_command.sendblue_send and literal values. If that fails, take HA out of the loop entirely with curl — Sendblue's response body states its complaint plainly:
curl -i -X POST 'https://api.sendblue.co/api/send-message' \
-H 'sb-api-key-id: YOUR_KEY_ID' \
-H 'sb-api-secret-key: YOUR_SECRET' \
-H 'Content-Type: application/json' \
-d '{"number":"+44XXXXXXXXXX","from_number":"+1XXXXXXXXXX","content":"curl test"}'
Then test the script from Developer Tools → Actions with a message filled in (running it by toggling the entity passes no variables and errors on the required field). Then fake an inbound message without involving Sendblue:
curl -X POST 'https://ha.yourdomain.com/api/webhook/<WEBHOOK_ID>?token=<SHARED_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{"is_outbound": false, "from_number": "+44XXXXXXXXXX", "content": "is the kitchen light on"}'
If that round-trips a reply to your phone, the HA side is complete. Finally, send a real text from a verified, allowlisted number to the Sendblue line to prove their webhook delivery.
Troubleshooting
Every entry below is a failure mode actually hit while building this.
| Symptom | Cause | Fix |
|---|---|---|
| Config won't reload: "while scanning a quoted scalar ... unexpected end of stream" | Stray quote character from copy/paste at the end of a YAML file | Delete it; run Developer Tools → YAML → Check configuration before restarting |
| Script "runs" but nothing sends, no errors | Placeholder from_number; or sandbox recipient never completed contact verification; or script invoked without data |
Use a real line from your account; complete the text-in handshake; call via Developer Tools → Actions with fields filled |
| Webhook fires but conditions always fail | Allowlist still contains a placeholder number; token missing from the dashboard URL; sender identity isn't your number (iMessage can send from an Apple ID email) | Check the trace's per-clause results; open Changed Variables on the condition step to see raw trigger.json and trigger.query |
| Assist answers but no reply arrives; traces all "finished" | The + was stripped: HA native type coercion turns a rendered +44... into an integer. Compounded by invisible characters (a zero-width space, U+200B) pasted into the script |
Normalise in the rest_command payload (`"+{{ number |
| Payload fix "doesn't work" | rest_command edits require a reload |
Developer Tools → YAML → reload REST commands, or restart |
| Reply leg fails with no visible error | Payload rendering happens inside rest_command and never appears in traces |
Settings → System → Logs immediately after a send; rest_command logs non-2xx responses with status codes |
Two diagnostic habits worth keeping. Traces are the ground truth: Settings → Automations & scenes → open the automation or script → Traces shows per-step results and rendered variable values, and the Changed Variables view shows the raw webhook payload verbatim. And deduplicate on message_handle if you ever process webhook events statefully — Sendblue may deliver duplicates.
Security model
The phone number is now a control surface for your house, so three layers apply. The sender allowlist in the conditions is the primary gate — keep it tight, and treat it as convenience-grade, since iMessage sender identity is hard to forge but plain SMS caller ID less so. Exposure (Step 4) is the real blast-radius control: curate the exposed-entities list as if anyone might text the number. The query-string token and high-entropy webhook ID are the transport-level checks; webhook IDs are otherwise unauthenticated, so entropy is the defence. Rotate the token and webhook ID if they ever leak, and keep API keys in secrets.yaml, never inline.
Extensions
Fixed commands can short-circuit the LLM: put a choose: block before conversation.process that matches trigger.json.content against keywords like "status" and replies from a template directly. Swapping agent_id to an LLM conversation entity upgrades the whole thing into a natural-language house chatbot with per-sender memory. Inbound media_url arrives as a CDN link (expires after roughly 30 days) if you want to route photos anywhere, and outbound media_url on the script already supports sending images. For multi-person households, add numbers to allowed_senders — each gets an isolated conversation thread automatically.