How to turn technicians' voice notes into job reports over iMessage
ZeroSettle gives a field-service company an iMessage number that technicians save and send a 30-second voice memo to after each job. Your webhook fetches the recording (converted to .m4a, with duration_ms), transcribes it, and a model pulls out the job number, parts used, time on site and follow-ups to write into Jobber, ServiceTitan or a spreadsheet; a 👍 tapback tells the tech the report is logged. Staff threads are ongoing conversations and inbound messages are never limited, so 30 techs sending 10 reports each a day never touch the new-contact limit on the $49-a-month Pro plan.
Key takeaways
- What it is: one iMessage number for the whole company that techs send voice memos and job photos to. Each one reaches your webhook as a signed
message.receivedevent, and you fetch the file with your bearer token. - Who it fits: HVAC, plumbing, cleaning, landscaping and construction companies whose techs carry iPhones.
- What you build: the transcription, the field extraction and the write to your job software. ZeroSettle delivers the audio file, not a transcript.
- 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: none for the reports. Only the office's first text to a tech who has never texted the number counts toward Pro's 5 new contacts a day.
- What it does not do: no SMS fallback for techs on Android, no video files, and no native Jobber or ServiceTitan integration.
Who uses iMessage for field reports?
iMessage field reports fit owners and operations managers of HVAC, plumbing, cleaning, landscaping and construction companies with crews in the field. ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you, that your code texts customers from and hears back on through a bearer token and signed webhooks. Here, the people texting the number are your own technicians.
The problem is the truck: a tech who just swapped a capacitor won't fill out a form in the driveway, so parts used, time on site and "they want a thermostat quote" never reach the office. Thirty seconds of talking into Messages is a smaller ask. Voice reports are one of the operations workflows in 50 ways businesses use iMessage.
How iMessage field reports work, step by step
The tech talks, ZeroSettle delivers the recording to your webhook, and your server files the job note and answers with a 👍. The tech types only when a required field is missing.
- At onboarding, each tech opts in and texts the number once, then saves it. Your staff table maps that handle to the tech's user in your job software.
- After each job, the tech sends a voice memo: job number, work done, parts, time on site, follow-ups. Once the line receives it, ZeroSettle POSTs a signed
message.receivedevent withkind: "audio"and anaudioobject whoseduration_msis filled in. - Your webhook answers
2xxand queues the work. The worker fetchesGET /api/imessage/messages/{id}/audio(theaudioobject'surl) with the same bearer token and gets an.m4a(AAC) file. - Speech-to-text makes a transcript, and a model extracts
job_number,parts,minutes_on_siteandfollow_upas JSON. No job number in the memo? Match the tech to today's schedule. - Your code writes the note through Jobber's or ServiceTitan's API, or to a sheet, then calls
POST /api/imessage/reactionswith"reaction": "like"on the memo. - A missing field gets one question from
POST /api/imessage/send, and the answer returns as anothermessage.received. - Photos of finished work arrive as
kind: "image", one per text (only the first photo of a message is delivered), and attach to the same job.
Attachment formats and sizes are in what an iMessage API can send and receive. The customer-facing side of the visit, technician on-the-way texts, runs on the same API.
Example conversation: field reports
One HVAC report from the company's side: a voice memo and its 👍, one missing field, and a photo that gets a 👍 once it is attached.
- Today 3:42 PM
- Technician: [Voice message, 0:27]👍
- Business: Logged to job 4471: replaced run capacitor, 1 hr on site. Did you change the filter too?
- Technician: yes
- Business: Added ✅
- Technician: [Photo: finished install]👍
How do you turn a voice note into a transcript and structured fields?
One webhook route does it: verify ZeroSettle's signature, fetch the audio, transcribe, extract, write to the job and react with like. The verifier is copied from the ZeroSettle webhook docs; tech_for(), transcribe(), extract() and the job-software calls are yours.
# reports.py: run with uvicorn reports:app --port 8100
import asyncio, hashlib, hmac, json, os, time
import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response
BASE = "https://portal.zerosettle.io"
TOKEN, SECRET = os.environ["ZEROSETTLE_TOKEN"], os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
app = FastAPI()
seen: set[tuple[int, str]] = set() # use your database in production
one_at_a_time = asyncio.Lock() # the line sends one message at a time
def verify(header: str, raw: bytes, tolerance: int = 300) -> bool:
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) > tolerance:
return False
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
async def post(zs: httpx.AsyncClient, path: str, body: dict) -> httpx.Response:
async with one_at_a_time: # queue here, not on the line
for _ in range(5):
r = await zs.post(path, json=body)
if r.status_code != 429 or r.json()["error"]["code"] != "line_busy":
break
await asyncio.sleep(2) # line_busy: retry after 2 s
return r # 403 recipient_opted_out is final
async def handle(m: dict) -> None:
tech = tech_for(m["handle"]) # your staff table
if tech is None:
return # not one of your techs
like = {"message_id": m["id"], "reaction": "like"}
async with httpx.AsyncClient(base_url=BASE, timeout=90,
headers={"Authorization": f"Bearer {TOKEN}"}) as zs:
if m.get("audio"): # a voice memo, already .m4a (AAC)
if (m["audio"].get("duration_ms") or 0) < 3000:
return # a pocket tap, not a report
audio = (await zs.get(m["audio"]["url"])).raise_for_status().content
report = await extract(await transcribe(audio), tech.jobs_today())
job = await save_report(tech, report) # Jobber, ServiceTitan or a sheet
await post(zs, "/api/imessage/reactions", like)
if question := missing_fields_question(job, report):
await post(zs, "/api/imessage/send", {"to": m["handle"], "text": question})
elif m.get("image"): # a photo of the finished work
photo = (await zs.get(m["image"]["url"])).raise_for_status().content
await attach_photo(tech.last_job(), photo)
await post(zs, "/api/imessage/reactions", like)
elif m.get("kind") in ("audio", "image") or not (m.get("text") or "").strip():
await post(zs, "/api/imessage/send", {"to": m["handle"], # a video, or a failed download
"text": "That one didn't come through. Can you resend it as a voice memo or photo?"})
else:
await answer_or_typed_report(tech, m["text"]) # "yes", or a typed report
@app.post("/webhook")
async def webhook(req: Request, tasks: BackgroundTasks):
raw = await req.body()
if not verify(req.headers.get("X-ZeroSettle-Signature", ""), raw):
return Response(status_code=401)
ev = json.loads(raw)
m = ev.get("message")
if ev["event"] != "message.received" or m is None or (m["id"], ev["event"]) in seen:
return {"ok": True} # echoes of your own sends, and retries
seen.add((m["id"], ev["event"]))
tasks.add_task(handle, m) # answer 2xx now; transcription runs after
return {"ok": True}
Register the route with PUT /api/imessage/webhook and store the signing secret, which is shown once. ZeroSettle expects a 2xx within 10 seconds and retries after 1, 5 and 20 seconds with the same message.id and event, so the dedupe keeps one memo from filing two notes. The post() helper hands the line one tapback or question at a time and retries line_busy after 2 seconds; it never retries 403 recipient_opted_out. The attachments docs describe the audio and image objects, and how webhook events, signing and retries work covers the rest.
Where a voice report goes wrong
- No job number in the memo: match the tech to today's schedule, and ask only when two open jobs fit.
- The recording didn't finish downloading: the event arrives with
audioset tonull, and the handler asks the tech to resend it. - A video of the install: it arrives with no file, so the handler asks for a photo or a voice memo instead.
- Thirty techs finishing at 5 PM: the line sends one message at a time, and a tapback or question that waits about 12 seconds behind others returns
429 line_busy. That is whypost()queues the handler's sends behind one lock and retries after 2 seconds.
The same fetch-then-tapback pattern files receipt photos for bookkeepers and customers' voice answers in iMessage feedback surveys.
Which limits and consent rules apply to field reports?
Field reports don't hit ZeroSettle's limits: inbound messages are never limited, tapbacks never count as new contacts, and a tech who texted the number first is an ongoing conversation. Consent is narrow: employees only, for internal use, with an opt-in at onboarding.
| What the company's number handles | Pro | Growth |
|---|---|---|
| Voice memos, photos and texts techs send | Unlimited | Unlimited |
| The line's 👍 tapbacks | Never a new contact | Never a new contact |
| Questions to a tech who has texted | Unlimited, but 2 unanswered, then 403 awaiting_reply; the tech's next memo resets it | Unlimited, but 2 unanswered, then 403 awaiting_reply; the tech's next memo resets it |
| First text to a tech who hasn't texted yet | 5 a day | 50 a day, 3 per rolling 15 minutes |
Source: ZeroSettle iMessage API docs, "Errors, limits, and compliance". The day runs 3 AM to 3 AM Eastern.
The math: 30 techs sending 10 reports each a day is 300 voice memos and 300 tapbacks a day, and none of it counts against a plan limit. Only onboarding can: if the office texts 30 new techs first on Pro, that is 5 a day over six days. If each tech texts the number first, it is zero.
Keep the opt-in, and watch the word "stop"
Keep each tech's opt-in record. A text with the word STOP in it, or a clear request like "take me off your list", opts the sender out across every number on your account, even "have to stop at the supply house first"; saying it in a voice memo doesn't count. Their memos still arrive, but tapbacks and questions to them return 403 recipient_opted_out until they text START as the whole message. More in how STOP and opt-outs work. Texting employees can carry its own rules where you operate; this isn't legal advice.
When ZeroSettle is not a fit for field reports
ZeroSettle is the wrong tool when techs carry Android phones, when you expect a ready-made Jobber or ServiceTitan app, when reports need video, or when nobody can run the code.
- Techs on Android: iMessage works between iPhone and other Apple products, and other phones use SMS or RCS, as Apple explains. ZeroSettle has no SMS fallback, so this workflow needs techs on iPhone. Keep your field app's form for the rest.
- You expect a native Jobber or ServiceTitan app: ZeroSettle has none; you build the write through their APIs. Jobber's pricing page mentions its Open API ("API Tour with API Access"), and ServiceTitan's help center says customers on The Works or Enterprise Plus package can create Customer-Built Apps, so check what your plan allows before you build.
- You need video reports: inbound video arrives with no file.
- You have no developer: transcription, extraction and the job write are code you run. ZeroSettle's hosted AI agent (Beta) answers texts but doesn't file job notes, and on a number it manages, inbound texts aren't sent to your webhook.
How much does iMessage for field reports cost?
Field reports cost the plan price only: ZeroSettle has no per-message fees, and messages are unlimited (counted) in both directions.
| Plan | Price | Fits a company that |
|---|---|---|
| Pro | $49/month, no setup fee | wants one number for the whole crew, self-serve, with unlimited messages with people who message you first and a 30-day money-back guarantee |
| Growth | From $599/mo | needs several numbers working as one, with dedicated support, set up after a demo |
Speech-to-text and model costs come from the providers you pick. AI credits apply only to the hosted AI agent, which this workflow doesn't use. See ZeroSettle plans and pricing, read the iMessage API docs, or get a number.
Frequently asked questions
Which transcription service should I use?
Any speech-to-text API that accepts .m4a; ZeroSettle only carries the file. OpenAI's file transcription and Amazon Transcribe's batch jobs both list M4A as an input format.
What should techs say in the memo?
A five-item card in the truck works: job number, what you did, parts, time on site, anything to follow up. Your code asks for whatever the memo leaves out.
Can the office answer a tech by hand?
Yes. Report threads show live in the ZeroSettle console, where a dispatcher can reply in the same thread, and the account owner can invite teammates to the console.
How long does ZeroSettle keep the recordings?
Messages are kept indefinitely for now, and GET /api/imessage/messages/{id}/audio returns a recording later. Store a copy with the job anyway, because deleting a tech's conversation removes its media too.
Sources
- ZeroSettle iMessage API docs: "Send a message" (reactions), "Attachments", "Read messages", "Webhooks, step by step" and "Errors, limits, and compliance". Checked October 8, 2026.
- ZeroSettle homepage: plans and prices. Checked October 8, 2026.
- File transcription, OpenAI API docs (supported input formats include
m4a). Checked October 8, 2026. - Data input and output, Amazon Transcribe Developer Guide (batch formats include M4A). Checked October 8, 2026.
- Understand approved integration paths in ServiceTitan, ServiceTitan Help Center. Checked October 8, 2026.
- Jobber pricing, Jobber ("API Tour with API Access"). Checked October 8, 2026.
- What is the difference between iMessage, RCS, and SMS/MMS?, Apple Support. Checked October 8, 2026.