ostradocs Menu

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.

Pythonread_code.py
"""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.

Node.jsread-code.mjs
// 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

HoldsSees
messages:readSender, times, source and whether a code was found. Never the body or the code.
messages:read_content as wellThe 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 linesTexts 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, with code_expires_at saying 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 body and code null 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_code is on by default for a key or person holding messages:read_content: each message.received then carries the code while it is under 15 minutes old. Send "include_code": false for an endpoint that should not receive codes. include_body is off unless you turn it on. Each delivery that carries either is written to the audit log.
  • events is a list of event names, or ["*"] for every event, which needs all three of messages:read, calls:read and lines:read.
Shellcreate-webhook.sh
#!/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}/test sends a webhook.test event now (30 an hour).
  • A replaced secret (POST /v1/webhooks/{endpoint_id}/secret) takes over at once; the old one stops.

Events

EventFires whenData
message.receivedA 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.receivedA 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_changedA check sees a new exit address on the line.{line_id, ipv4, ipv6, previous: {ipv4, ipv6}}
new_carrier_ip.finishedA 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.downOstra closed the line's ports, by any path.{line_id}
line.upOstra reopened them.{line_id}
line.suspendedOstra suspended the line, or your whole account (one event for each line).{line_id}
line.unsuspendedOstra lifted the suspension, of the line or of your account (one event for each line that works again).{line_id}
line.assignedYour organization was given the line.{line_id, starts_at, ends_at}
line.unassignedThe assignment ended, or reached its fixed end.{line_id, starts_at, ends_at}
line.renewal_upcomingStripe 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.testThe 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:

JSONA message.received event from an endpoint with include_code
{
  "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:

TextOstra-Signature
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.

Node.jswebhook-receiver.mjs
// 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.

Pythonwebhook_receiver.py
"""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.