Texts and webhooks
Texts to a line’s number reach you two ways: you read them over the REST API, or Ostra posts each one to your webhook endpoint, signed. Either way you see only texts that arrived while your organization held the line.
How a text reaches you
The phone passes each text to Ostra the moment it arrives, and the Mac that runs the phones also reads them off the phone as a backstop. A text’s source says which: fast, backstop, both, or simulated on the sandbox line. Ostra reads a code out of the body when it finds one (has_code), stores the body and the code encrypted, and keeps them in the order it stored them, so a text that came in late through the backstop is never skipped by a cursor.
A line takes SMS and iMessage. A text’s service says which carried it: sms, imessage, or null when Ostra does not know (a text only the phone’s own path passed on, and every simulated text). When Ostra reads an iMessage off the phone it keeps the text, emoji included, and leaves out reactions, attachments and messages already unsent. An iMessage sender can be an email address.
List texts
GET /v1/lines/{line_id}/messages (scope messages:read) gives texts oldest first: sender, times, source, whether a code was found. Pass limit (1 to 200, default 50) and the next_cursor of the last answer as cursor to go on. Cursors are opaque; there is no since=.
A list carries no body and no code unless you ask with include_content=true, which needs messages:read_content and writes every text returned to Ostra’s audit log. To show texts to a person, list them without content and open the one they choose.
Wait for the next text
GET /v1/lines/{line_id}/messages/wait holds the request until the first text after cursor arrives (no cursor: the next text from now), for up to timeout seconds (1 to 90, default 60). It answers 200 with the same shape as the list, or 204 with an Ostra-Cursor header to wait on from. limit=1 hands you one text at a time, so when two codes arrive together the next wait returns the second at once. At most 5 waits at once per line per organization.
Read a code
The pattern: wait without content, skip texts with no code, open the one with a code. Only that text is decrypted and logged. Both programs print the code alone, for a test to use.
"""Wait for the next text on a line that carries a code, and print the code.
Texts are read without their bodies first, so a text with no code is never
opened; the one with a code is opened once, which Ostra writes to its audit log.
The key needs messages:read and messages:read_content.
Python 3.9 or later, standard library only.
"""
import json
import os
import sys
import time
import urllib.parse
import urllib.request
API = os.environ["OSTRA_API_BASE_URL"]
KEY = os.environ["OSTRA_API_KEY"]
LINE = os.environ.get("LINE", "LAX-SANDBOX-00")
GIVE_UP = time.time() + int(os.environ.get("WAIT_SECONDS", "120"))
def get(path, query=None):
url = API + path + ("?" + urllib.parse.urlencode(query) if query else "")
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + KEY})
with urllib.request.urlopen(req, timeout=100) as r:
return r.status, r.headers, (json.load(r) if r.status == 200 else None)
cursor = None # None: wait for the next text from now
while time.time() < GIVE_UP:
query = {"timeout": 60, "limit": 1}
if cursor:
query["cursor"] = cursor
status, headers, page = get(f"/v1/lines/{LINE}/messages/wait", query)
if status == 204: # nothing arrived; wait on from where the server says
cursor = headers["Ostra-Cursor"]
continue
cursor = page["next_cursor"]
text = page["messages"][0]
if not text["has_code"]:
continue
_, _, opened = get(f"/v1/lines/{LINE}/messages/{text['id']}")
if opened["message"]["code"]:
print(opened["message"]["code"])
sys.exit(0)
sys.exit("no code arrived")LINE defaults to the sandbox line. In a test, start this before your service sends the text, so the wait from now sees it.
// Wait for the next text on a line that carries a code, and print the code.
// Texts are read without their bodies first; only the one with a code is
// opened, once, and Ostra writes that view to its audit log.
// The key needs messages:read and messages:read_content. Node 18 or later.
const api = process.env.OSTRA_API_BASE_URL
const auth = { authorization: `Bearer ${process.env.OSTRA_API_KEY}` }
const line = process.env.LINE ?? 'LAX-SANDBOX-00'
const giveUp = Date.now() + Number(process.env.WAIT_SECONDS ?? 120) * 1000
let cursor // undefined: wait for the next text from now
while (Date.now() < giveUp) {
const query = new URLSearchParams({ timeout: '60', limit: '1', ...(cursor ? { cursor } : {}) })
const res = await fetch(`${api}/v1/lines/${line}/messages/wait?${query}`, { headers: auth })
if (res.status === 204) {
cursor = res.headers.get('ostra-cursor') // nothing arrived; wait on from here
continue
}
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`)
const page = await res.json()
cursor = page.next_cursor
const [text] = page.messages
if (!text.has_code) continue
const opened = await (await fetch(`${api}/v1/lines/${line}/messages/${text.id}`, { headers: auth })).json()
if (opened.message.code) {
console.log(opened.message.code)
process.exit(0)
}
}
console.error('no code arrived')
process.exit(1)Opening one text is GET /v1/lines/{line_id}/messages/{message_id} (scopes messages:read and messages:read_content). Ostra writes the view to its audit log before it decrypts anything.
Who can see a code
| Holds | Sees |
|---|---|
messages:read | Sender, times, source and whether a code was found. Never the body or the code. |
messages:read_content as well | The body and the code, each read written to the audit log. Webhooks may carry the code (include_code) or the body (include_body) only for a key or person holding it. |
| A key limited to some lines | Texts on those lines and the sandbox line only. |
A person in the dashboard sees a list without bodies and opens one text at a time; the Ostra apps and the browser extension show a code in a notification only when the person turns that on.
How long texts are kept
- The code: 15 minutes after the text was stored, then cleared (
"code": null, withcode_expires_atsaying when). - The body: 30 days.
- Your view: only texts that arrived while your organization held the line. A text from before is never shown, nor one after your assignment ends.
- A stored text that cannot be decrypted comes back with
bodyandcodenull and"decrypt_error": true; Ostra is told at once.
Name a number
Give a number that texts or calls your lines a name of your own, such as Chase for +1 888 555 0101: PUT /v1/labels/{number} with {"label": "Chase"} (scope labels:write; 1 to 60 characters). Every text and call from that number then carries the name as sender_label, in the lists, the long poll, one text or call opened, message.received and call.received, and the Ostra apps and browser extension show it with the number. Write the number any way you like: Ostra matches it in E.164 (+18885550101), a short code as sent and an iMessage address in lower case. GET /v1/labels (scope messages:read) lists your names and DELETE /v1/labels/{number} removes one. Names cover every line, so a key limited to some lines cannot list or change them; it still sees sender_label on its own lines, and none on the sandbox line, where it picks the sender itself. A name is your own data, not a text’s content: it is not sealed and showing it records nothing, but setting and removing one is written to Ostra’s audit log, and your organization keeps at most 1,000.
Webhooks
An endpoint receives events for every line your organization holds. Create one with POST /v1/webhooks; the key needs webhooks:write and the scopes of what the endpoint carries: messages:read for message.received, calls:read for call.received, lines:read for the line.* events and new_carrier_ip.finished, and messages:read_content for include_code or include_body. A key limited to some lines cannot manage endpoints, since events cover every line.
include_codeis on by default for a key or person holdingmessages:read_content: eachmessage.receivedthen carries the code while it is under 15 minutes old. Send"include_code": falsefor an endpoint that should not receive codes.include_bodyis off unless you turn it on. Each delivery that carries either is written to the audit log.eventsis a list of event names, or["*"]for every event, which needs all three ofmessages:read,calls:readandlines:read.
#!/bin/sh
# An endpoint for texts on every line you hold, with the code in each event.
# include_code needs messages:read_content. The answer's secret (ows_...) is
# shown once: keep it where your receiver can read it.
curl -sS -X POST "$OSTRA_API_BASE_URL/v1/webhooks" \
-H "Authorization: Bearer $OSTRA_API_KEY" \
-H 'content-type: application/json' \
-d '{"url": "https://hooks.example.com/ostra", "events": ["message.received"], "include_code": true}'- The URL is
https, with no user or password, at most 2048 characters, on a host that resolves only to public addresses (checked when you create it and again at every delivery). Redirects are not followed. - At most 10 endpoints per organization.
POST /v1/webhooks/{endpoint_id}/testsends awebhook.testevent now (30 an hour). - A replaced secret (
POST /v1/webhooks/{endpoint_id}/secret) takes over at once; the old one stops.
Events
| Event | Fires when | Data |
|---|---|---|
message.received | A text reaches a line your organization holds. | {line_id, message: {id, sender, sender_label, received_at, source, service, has_code, code?, body?}}. sender_label is the name your organization gave the number when the text arrived, or null (events from before 2026-10-16 lack it). code only with include_code while the code is under 15 minutes old; body only with include_body. Each delivery carrying either is written to the audit log. |
call.received | A call to a line your organization holds ends: an Ostra app or the dashboard answered it, it was missed, the caller left a voicemail, or it was refused unanswered. | {line_id, call: {id, caller, sender_label, caller_id_source, started_at, ended_at, duration_s, outcome, has_code}}. sender_label is the name your organization gave the number when the call ended, or null. Never keyed digits, a transcript or a code: read those with GET /v1/lines/{line_id}/calls/{call_id}. |
line.ip_changed | A check sees a new exit address on the line. | {line_id, ipv4, ipv6, previous: {ipv4, ipv6}} |
new_carrier_ip.finished | A new carrier IP request your organization made ends: it succeeds, fails, expires, or is refused before the Mac takes it. | {line_id, job_id, status} |
line.down | Ostra closed the line's ports, by any path. | {line_id} |
line.up | Ostra reopened them. | {line_id} |
line.suspended | Ostra suspended the line, or your whole account (one event for each line). | {line_id} |
line.unsuspended | Ostra lifted the suspension, of the line or of your account (one event for each line that works again). | {line_id} |
line.assigned | Your organization was given the line. | {line_id, starts_at, ends_at} |
line.unassigned | The assignment ended, or reached its fixed end. | {line_id, starts_at, ends_at} |
line.renewal_upcoming | Stripe announced the subscription's next renewal; one per line held. | {line_id, renews_at, held_since}; renews_at is null if Stripe gave no date. |
webhook.test | The test route or button. Never subscribed to. | {endpoint_id} |
What a delivery looks like
Each delivery is a POST with content-type: application/json and these headers: Ostra-Event-Id (the same on every attempt), Ostra-Delivery-Id, Ostra-Timestamp (Unix seconds of this attempt) and Ostra-Signature. The body:
{
"id": "evt_6f9c2d1e4b8a4c7f9e0d1a2b3c4d5e6f",
"type": "message.received",
"created_at": "2026-10-12T17:05:02.114Z",
"organization_id": "3d6f2a8e-1b4c-4e9a-8f7d-0c5b2a1e9d4f",
"sandbox": true,
"data": {
"line_id": "LAX-SANDBOX-00",
"message": {
"id": "6c1f0e52-7d1b-4f8e-9a51-2b7c4e0d93aa",
"sender": "+13105550100",
"sender_label": null,
"received_at": "2026-10-12T17:05:02.114Z",
"source": "simulated",
"service": null,
"has_code": true,
"code": "482915"
}
}
}Verify every delivery
The signature header has this form, where the HMAC-SHA256 is taken under the endpoint’s secret:
t=<Ostra-Timestamp>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">Recompute it over the exact bytes you received, before parsing them; compare in constant time; refuse a t more than 300 seconds from your clock; and drop an event whose id you have already handled, since an event can arrive more than once. Take the id from the verified body: the headers are not signed. The HMAC key is the whole secret, ows_ included.
// A webhook receiver that checks every delivery's signature before trusting it.
// OSTRA_WEBHOOK_SECRET is the endpoint's whole secret, ows_ included. Node 18 or later, no packages.
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
const secret = process.env.OSTRA_WEBHOOK_SECRET
const TOLERANCE = 300 // seconds a signature's time may be from this clock
// Event id -> when handled. An event can arrive more than once: retries run for
// 24 hours, and a failed delivery can be sent again by hand for 30 days. This
// in-memory map is for the sample; keep handled ids in your own database.
const seen = new Map()
const KEEP_MS = 31 * 24 * 3600 * 1000
/** Ostra-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">. */
export function verifyOstraWebhook(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(',').map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)]))
const t = Number(parts.t)
if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > TOLERANCE) return false
const want = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest()
const got = Buffer.from(parts.v1 ?? '', 'hex')
return got.length === want.length && timingSafeEqual(got, want)
}
createServer((req, res) => {
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const raw = Buffer.concat(chunks) // the exact bytes received, before any parsing
if (!verifyOstraWebhook(secret, String(req.headers['ostra-signature'] ?? ''), raw)) {
res.writeHead(400).end()
return
}
res.writeHead(204).end() // answer within 10 seconds; do the work after
// Deduplicate on the id inside the signed body, never on a header the signature does not cover.
const event = JSON.parse(raw)
const now = Date.now()
for (const [id, at] of seen) if (now - at > KEEP_MS) seen.delete(id)
if (seen.has(event.id)) return
seen.set(event.id, now)
if (event.type === 'message.received') {
const m = event.data.message
// Hand m.code to your test here. Codes are sensitive: do not log them.
console.log(`${event.data.line_id}: text from ${m.sender}, ${m.code ? 'code received' : 'no code'}`)
} else {
console.log(event.type)
}
})
}).listen(Number(process.env.PORT ?? 8080), '127.0.0.1', () => console.log(`listening on ${process.env.PORT ?? 8080}`))Ostra's checks send this receiver a delivery the real server signed, then the same delivery again, again with a changed event id header, with one byte of the body changed, with an old timestamp and with no signature: it handles the first once and refuses the last three.
"""A webhook receiver that checks every delivery's signature before trusting it.
OSTRA_WEBHOOK_SECRET is the endpoint's whole secret, ows_ included.
Python 3.9 or later, standard library only.
"""
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["OSTRA_WEBHOOK_SECRET"].encode()
TOLERANCE = 300 # seconds a signature's time may be from this clock
# Event id -> when handled. An event can arrive more than once: retries run for
# 24 hours, and a failed delivery can be sent again by hand for 30 days. This
# in-memory dict is for the sample; keep handled ids in your own database.
SEEN = {}
KEEP = 31 * 24 * 3600
def verify_ostra_webhook(secret, header, raw_body, now=None):
"""Ostra-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">."""
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs((now or time.time()) - t) > TOLERANCE:
return False
want = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts.get("v1", ""))
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0))) # the exact bytes received
if not verify_ostra_webhook(SECRET, self.headers.get("ostra-signature", ""), raw):
self.send_response(400)
self.end_headers()
return
self.send_response(204) # answer within 10 seconds; do the work after
self.end_headers()
# Deduplicate on the id inside the signed body, never on a header the signature does not cover.
event = json.loads(raw)
now = time.time()
for event_id, at in list(SEEN.items()):
if now - at > KEEP:
del SEEN[event_id]
if event["id"] in SEEN:
return
SEEN[event["id"]] = now
if event["type"] == "message.received":
message = event["data"]["message"]
# Hand message["code"] to your test here. Codes are sensitive: do not log them.
got = "code received" if message.get("code") else "no code"
print(f"{event['data']['line_id']}: text from {message['sender']}, {got}", flush=True)
else:
print(event["type"], flush=True)
def log_message(self, *args):
pass
port = int(os.environ.get("PORT", "8080"))
print(f"listening on {port}", flush=True)
HTTPServer(("127.0.0.1", port), Receiver).serve_forever()Both receivers listen on this machine only. Ostra delivers only to https URLs, so run them behind your own HTTPS server.
Delivery and retries
A delivery succeeds on any 2xx status within 10 seconds of starting, DNS and TLS included; the answer’s body is not read, so answer first and do the work after. Anything else is tried again about 30 seconds, 2 minutes, 10 minutes, 1 hour, 3, 6, 12 and 24 hours after the event, then marked failed. The delivery log (GET /v1/webhooks/{endpoint_id}/deliveries) keeps every delivery for 30 days, and POST .../deliveries/{delivery_id}/retry sends a failed one again, once. A disabled or deleted endpoint gets nothing, retries included.
Calls
GET /v1/lines/{line_id}/calls (scope calls:read, plus calls:read_content for transcripts, digits and codes) lists calls to a line the same way texts are listed, each with how it ended: in_progress, answered (an Ostra app or the dashboard took it), missed, voicemail or rejected (refused unanswered, because the line could not ring). Keyed numbers are captured first; spoken numbers and voicemail are transcribed only where the line’s transcription is on, after an announcement to the caller. Nothing is ever recorded but that voicemail, and its audio is deleted at Twilio once it is transcribed.
A call rings the Ostra iPhone app where Ring this iPhone is on, the Ostra Mac app where Ring this Mac is on, and the dashboard in a browser set to ring, all at once (up to ten of them on one call, the iPhones first), for up to 60 seconds after the first 8, in which keyed digits are captured; the first to answer takes it. The API neither answers nor places a call: call.received tells your endpoint when a call ends, and the list and the call’s own route carry what it held.