Log iMessages in HubSpot or Salesforce and send them from CRM workflows
ZeroSettle’s docs list a built-in Close integration, describe a HubSpot app (check the console’s Settings → CRM to see whether you can connect it yet) and list Salesforce as coming soon. If you’d rather own the logic, or need Salesforce today, a small service you run can log every iMessage on the contact record and let a CRM workflow send one. The service turns ZeroSettle's signed message.received and message.sent webhook events into a HubSpot communication or a Salesforce Task, and a workflow sends through it with POST /api/imessage/send only when the contact's consent property is true. Because sends from the ZeroSettle console, the API and MCP all echo back as message.sent, the timeline stays complete wherever a rep typed, and a nightly sync with GET /api/imessage/opt-outs sets a "do not text" property.
Key takeaways
- What it is: ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you in a server facility, that your code texts customers from and hears back on through a bearer token and signed webhooks.
- CRM connection: this guide builds it yourself, for Salesforce (coming soon as a built-in integration) or any CRM where you want your own logic. You run one small service: a webhook route (ZeroSettle to CRM), a send route (CRM to ZeroSettle) and a nightly opt-out job.
- What lands on the timeline: every inbound and outbound iMessage, tapbacks and failed sends, whether a workflow, your code or a rep in the console sent them.
- Price: Pro is $49/month for one number. Growth, for several numbers working as one, is from $599/mo. No per-message fees.
- Main limit: a workflow can open conversations with 5 new contacts a day per number on Pro, or 50 per number on Growth. Contacts who texted first and ongoing conversations don't count toward it.
- What it does not do: no SMS fallback, so contacts without iMessage can't be reached, and no read receipts to log.
Who uses iMessage logging in HubSpot or Salesforce?
Sales and RevOps teams use ZeroSettle this way when every iMessage with a lead or customer has to sit on the HubSpot or Salesforce record next to calls and emails. This replaces reps texting from their own phones, which leaves no CRM history, nothing to hand over when a rep leaves, and no record of who agreed to be texted or who said STOP.
With ZeroSettle the team texts from a business number, in the console or from workflows, and the webhook feeds the CRM; someone has to host a small service. Other jobs are in 50 ways businesses use iMessage.
How iMessage logging in HubSpot or Salesforce works, step by step
ZeroSettle's webhook carries every message on the line to a service you run, which writes it to the CRM record; workflows send through the same service.
- Log inbound texts. ZeroSettle POSTs a signed
message.receivedevent about 1–2 seconds after a text reaches the line; the service finds the contact by phone number (or email, for an email handle) and logs a HubSpot communication or a completed Salesforce Task. - Log outbound texts only from
message.sent. Console, API and MCP sends all echo back as that event, so logging at send time as well would show each text twice. - Log tapbacks and failures from
reaction.received(itstarget_idis the message reacted to) andmessage.failed(a send recorded but not delivered). - Send from a workflow. A HubSpot webhook action or a Salesforce Flow HTTP Callout calls your service, which calls
POST /api/imessage/sendonly when the consent property is true and has a timestamp. The ZeroSettle token stays in your service. - Sync opt-outs. STOP replies arrive as
message.received. A nightly job readsGET /api/imessage/opt-outs, which lists each handle withopted_out_at, and sets a "do not text" property. - With several numbers, store
line_idfor each contact, not the number. If a number is ever replaced, the line keeps itsline_id, history and STOPs (see what happens if a number is flagged).
Signing and retries are covered in how to receive iMessages with a webhook. ZeroSettle POSTs each event to one webhook URL, so if a Slack support relay or a Zapier, n8n or Make flow needs the same messages, fan them out from this service.
Example conversation: a proposal follow-up logged in HubSpot
Here a HubSpot workflow sends the first text, the rep answers in the ZeroSettle console, and the contact ends with a tapback.
- Tue 10:14 AM
- Business: Hi Tom, it's Ana from Fernway, following up on the proposal. Any questions from your CFO?
- Customer: she wants annual billing
- Business: Easy. I'll send the updated quote today.👍
| Webhook event | Where it came from |
|---|---|
message.sent | The HubSpot workflow's text, sent through the service |
message.received | Tom's reply |
message.sent | Ana's text, typed in the ZeroSettle console |
reaction.received | Tom's 👍, whose target_id is Ana's text |
Result: 3 iMessages and 1 reaction on Tom's timeline, each an SMS communication with a body like "iMessage message.received #44: she wants annual billing", since HubSpot's channel types are only SMS, WhatsApp and LinkedIn message.
Setup: a webhook-to-CRM logger and a workflow action
The integration is one service: /zerosettle receives ZeroSettle's webhook, /send serves workflows, and a nightly job syncs opt-outs. Register /zerosettle with PUT /api/imessage/webhook and keep the signing secret, shown once. verify is the verifier from the webhook steps in the docs; the CRM side here is HubSpot.
# crm_bridge.py: run with uvicorn crm_bridge:app --port 8100
import hashlib, hmac, json, os, time
import requests
from fastapi import BackgroundTasks, FastAPI, Request, Response
BASE = "https://portal.zerosettle.io"
ZS = {"Authorization": f"Bearer {os.environ['ZEROSETTLE_TOKEN']}"}
SECRET = os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
HUB = "https://api.hubapi.com/crm/objects/2026-09"
HS = {"Authorization": f"Bearer {os.environ['HUBSPOT_TOKEN']}"}
yes = lambda v: str(v).lower() == "true"
app = FastAPI()
def verify(header, raw):
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
except Exception:
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
def contact_id(handle): # HubSpot matches US phones without +1
prop, value = ("email", handle) if "@" in handle else ("phone", handle.removeprefix("+1"))
f = {"propertyName": prop, "operator": "EQ", "value": value}
r = requests.post(f"{HUB}/contacts/search", headers=HS, timeout=10,
json={"filterGroups": [{"filters": [f]}]}).json()
return r["results"][0]["id"] if r.get("results") else None
def log(event, m): # dedupe on (m["id"], event) in your DB
if not (cid := contact_id(m["handle"])):
return # not in the CRM yet: see the FAQ
on = f" on #{m['target_id']}" if m.get("target_id") else "" # a tapback's target
requests.post(f"{HUB}/communications", headers=HS, timeout=10, json={
"properties": {"hs_communication_channel_type": "SMS", # no iMessage type
"hs_communication_logged_from": "CRM",
"hs_communication_body": f"iMessage {event} #{m['id']}{on}: {m.get('text') or ''}",
"hs_timestamp": m["ts_ms"]},
"associations": [{"to": {"id": cid}, "types": [
{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 81}]}]})
if m.get("line_id"): # Growth: keep the contact's line
requests.patch(f"{HUB}/contacts/{cid}", headers=HS, timeout=10,
json={"properties": {"zerosettle_line_id": m["line_id"]}})
@app.post("/zerosettle")
async def zerosettle_webhook(request: Request, tasks: BackgroundTasks):
raw = await request.body()
if not verify(request.headers.get("X-ZeroSettle-Signature", ""), raw):
return Response(status_code=401)
ev = json.loads(raw)
if ev.get("message"): # typing events carry no message
tasks.add_task(log, ev["event"], ev["message"])
return Response(status_code=200) # logs everything, replies to nothing
def send(c):
body = {"to": c["phone"], "line_id": c.get("zerosettle_line_id") or None,
"text": f"Hi {c['firstname']}, it's Ana from Fernway, following up on the proposal. Any questions from your CFO?"}
while True: # use a job queue in production
r = requests.post(f"{BASE}/api/imessage/send", headers=ZS, json=body, timeout=90)
wait = r.json()["error"].get("retry_after_s", 2) if r.status_code == 429 else 0
if not 0 < wait <= 900: # longer = the daily cap: requeue for 8 am
return r # 403 recipient_opted_out: set do-not-text
time.sleep(wait)
@app.post("/send")
async def send_from_workflow(request: Request, tasks: BackgroundTasks):
if request.headers.get("Authorization") != f"Bearer {os.environ['CRM_SHARED_SECRET']}":
return Response(status_code=401)
c = await request.json() # the workflow's custom request body
if yes(c.get("imessage_consent")) and c.get("imessage_consent_at") and not yes(c.get("imessage_do_not_text")):
tasks.add_task(send, c)
return {"ok": True}
def sync_opt_outs(): # nightly, from cron
for o in requests.get(f"{BASE}/api/imessage/opt-outs", headers=ZS, timeout=30).json()["opt_outs"]:
if cid := contact_id(o["handle"]):
requests.patch(f"{HUB}/contacts/{cid}", headers=HS, timeout=10,
json={"properties": {"imessage_do_not_text": "true"}})
In HubSpot, add a Send a webhook action to a contact-based workflow and choose Customize request body with phone, firstname, the consent properties and, on Growth, zerosettle_line_id. For authentication, add an API key named Authorization in the request header, with the value Bearer and your shared secret. The action needs Data Hub Professional or Enterprise.
Both routes answer at once and work in the background. ZeroSettle retries a webhook that isn't answered within 10 seconds. A ZeroSettle send can take up to about 75 seconds, and HubSpot retries a failed webhook action for up to three days, so a /send route that waits on the send risks duplicate texts. Cache contact lookups as volume grows: HubSpot's search allows five requests per second per account.
In Salesforce, the handler finds the Contact or Lead with a SOSL search in phone fields and inserts a completed Task linked through WhoId. A Flow calls /send with an HTTP Callout action, whose named credential holds the service's URL.
Limits and consent for CRM-triggered iMessages
CRM workflows obey the same ZeroSettle limits as any caller, the console included. Contacts who texted first, and ongoing conversations, never count as new contacts.
| Limit | Pro | Growth | API answer |
|---|---|---|---|
| New contacts per number per day (3 AM to 3 AM Eastern) | 5 | 50 | 429 new_contact_limit |
| New contacts per number per rolling 15 minutes | — | 3 | 429 new_contact_limit |
| Unanswered messages to one contact | 2 | 2 | 403 awaiting_reply |
| Contact texted STOP | Blocked | Blocked | 403 recipient_opted_out |
Source: ZeroSettle iMessage API docs, "Errors, limits, and compliance". With several numbers, a send is refused for the limit only when every number is full, or the named line is.
The limit math for one workflow enrollment
Say a HubSpot workflow enrolls 45 webinar sign-ups on Monday morning who ticked "text me about my demo", none of whom has texted your number.
- Pro: 5 go out Monday, using the day's 5. The other 40 get
429 new_contact_limituntil the 3 AM Eastern reset, so the sample hands them back to your queue and 5 more go each day after 8 am local time: the last ones go out over a week later. For enrollments this size, book a demo to upgrade to Growth. - Growth with three numbers: 3 per number per 15 minutes is 9 per window, so all 45 are out in about an hour.
- A three-step sequence to a lead who never answers stops at two: on either plan the third step returns
403 awaiting_reply. Either way, repeated texts to people who never answer are the fastest way to get a number flagged by Apple.
Consent comes first
Send only when the consent property is true and its timestamp records when the contact gave their number for texting, so you can show the record. Send nothing promotional before 8 am or after 9 pm in the contact's time zone; ZeroSettle’s docs say US marketing texts generally need written consent and note TCPA damages of $500 to $1,500 per message sent without consent. This isn’t legal advice.
Opt-outs sync back so other channels that read the CRM skip anyone who said STOP; ZeroSettle already blocks them on every number on your account. The nightly job only ever sets the property, because the list holds only STOPs received since ZeroSettle's enforcement launched; how STOP and opt-outs work covers START.
When ZeroSettle is not a fit for CRM logging
ZeroSettle is the wrong choice for CRM texting in these cases:
- You need a no-code Salesforce app today. Salesforce is coming soon, and HubSpot’s app may not be open to every account yet. If you'd rather buy than build, ZeroSettle vs Project Blue compares the two for sales teams.
- You'd enroll whole CRM lists in text sequences. New-contact limits and consent rules apply, and ZeroSettle suspends lines used for unsolicited messaging.
- Many of your contacts use Android. There is no SMS fallback, so a contact without iMessage gets nothing.
- You need SMS in the same thread. ZeroSettle carries iMessage only, so SMS would come from a second tool and log separately.
- Reps expect read tracking as with email. The API reports
message.sent(the message left the line) andmessage.failed, not delivered or read status.
How much does iMessage logging in a CRM cost?
ZeroSettle charges the plan price and nothing per message; outbound and inbound messages are unlimited. Add hosting for the service and the CRM edition your workflow action needs.
| Plan | Price | Fits |
|---|---|---|
| Pro | $49/month | One number to start, 5 new contacts a day per number, unlimited messages with people who message you first, self-serve, no setup fee |
| Growth | From $599/mo | 3+ numbers working as one, 50 new contacts a day per number, set up after a demo |
Both plans are billed monthly with a 30-day money-back guarantee on the subscription. See ZeroSettle's plans, read the iMessage API docs, or get a number.
Frequently asked questions
Can each sales rep text from their own number?
Yes, once the account has several numbers (Growth, or Pro with numbers added). Each number is a line with its own line_id: pass a rep's line_id to POST /api/imessage/send and the conversation starts from that rep's number, which has its own daily new-contact limit (5 on Pro, 50 on Growth). Without a line_id, a contact already in a conversation always hears from that conversation's line.
What happens when someone who isn't in the CRM texts the number?
The sample logs nothing for a handle it can't match, and the text stays readable in GET /api/imessage/messages. Create a HubSpot contact or Salesforce Lead for unknown handles in log, or backfill that one conversation with ?handle= once a rep adds the contact.
Can I import past conversations into the CRM?
Yes. GET /api/imessage/messages?after_id=0 pages forward through every stored message in both directions, up to 500 per page, and ZeroSettle keeps messages indefinitely for now. Skip message ids you have already logged so the backfill and the webhook never log a text twice.
Can the hosted AI agent answer leads while the CRM logs the thread?
Yes, by polling. On a line group Managed by AI Agent (Beta), inbound texts are not POSTed to your webhook, but the conversation stays readable in GET /api/imessage/messages. Sends from that group are refused (403 managed_by_zerosettle), so workflows start conversations with POST /api/imessage/agent/start, which uses AI credits.
Sources
- ZeroSettle iMessage API docs: "Your iMessage number", "Send a message", "Read messages", "AI agent", "Webhooks, step by step", "CRM integrations" and "Errors, limits, and compliance". Checked October 12, 2026.
- Communications API, HubSpot Developers: channel types, properties and the create endpoint. Checked October 8, 2026.
- Associate records: association type IDs, HubSpot Developers (communication to contact is 81). Checked October 8, 2026.
- CRM Search API, HubSpot Developers: phone-number matching and the search rate limit. Checked October 8, 2026.
- How to trigger webhooks in HubSpot contact-based workflows, HubSpot Knowledge Base: subscriptions, request body, authentication and retries. Checked October 8, 2026.
- Create a Custom API Data Flow, Salesforce Trailhead (Quick Start: Create HTTP Callouts with Flow Builder): the HTTP Callout element and its named credential. Checked October 8, 2026.
Changelog
- First published.