Skip to main content

cleatapi

Python client for the Cleat API: read the SMS/2FA codes and call transcripts that arrive on your Cleat phone lines.

Cleat rents ID-verified US mobile numbers that receive text messages and transcripts of incoming calls. A line is $24.99 a month or $249.90 a year. A line belongs to one identity-verified owner, and teammates in the same workspace read the same inbox at no extra cost. Codes show up in a web inbox, by email, on Telegram, by signed webhook, through this REST API, and via an MCP server. Lines are receive-only: no outbound texts, no outbound calls, no 911.

Install

pip install cleatapi
import cleatapi

Python 3.10 or newer. The only runtime dependency is httpx.

Both the distribution and the import name are cleatapi. The bare name cleat on PyPI belongs to an unrelated project, so pip install cleat would install something else. The repository keeps the name cleat-python.

Until the first release lands on PyPI, install it from here instead:

pip install git+https://github.com/davidcleat/cleat-python

Example

Waiting for a code, for example in an end-to-end test that signs your own QA account into your own staging app:

import os

from cleatapi import CleatClient, CleatTimeoutError

with CleatClient(os.environ["CLEAT_API_KEY"]) as cleat:
    lines = cleat.list_lines()
    for line in lines:
        print(line.id, line.phone_e164, line.status, line.label)

    line = lines[0]

    # Trigger the code however your app does it, then wait for it to land.
    # wait_for_code only looks at messages that arrive after this call, so
    # start it before (or right after) asking for the code.
    try:
        code = cleat.wait_for_code(line.id, timeout=180)
    except CleatTimeoutError as error:
        raise SystemExit(error)

    print("code:", code)

wait_for_code returns the code Cleat extracted from the message. Extraction is best effort, so when the exact text matters — a code split oddly, a message with more than one number in it, or a code read out over a call — wait for the whole message instead and read body yourself:

message = cleat.wait_for_message(line.id, timeout=180)
print(message.code, "from", message.from_)
print(message.body)          # the full text, or the call transcript

Reading history instead of waiting:

from datetime import datetime, timedelta, timezone

since = datetime.now(timezone.utc) - timedelta(days=1)
for message in cleat.list_messages(line.id, after=since, limit=200):
    print(message.received_at, message.label or message.from_, message.body)

Passing after returns messages oldest first, so you can walk them in order and keep the last received_at_raw as your cursor for the next call. Passing before pages backwards, newest first.

There is an async client with the same surface:

import asyncio

from cleatapi import AsyncCleatClient


async def main() -> None:
    async with AsyncCleatClient() as cleat:      # reads CLEAT_API_KEY
        line = (await cleat.list_lines())[0]
        print(await cleat.wait_for_code(line.id, timeout=180))


asyncio.run(main())

Webhooks

Cleat POSTs {"type": "message.received", "data": <message>} to your endpoint as soon as it has stored a message, signed with the endpoint's whsec_ secret. Verify over the raw request bytes, before anything parses or re-serialises them. Here it is in Flask:

import os

from flask import Flask, request

from cleatapi import CleatSignatureError, verify_webhook

app = Flask(__name__)
SECRET = os.environ["CLEAT_WEBHOOK_SECRET"]


@app.post("/cleat-webhook")
def cleat_webhook():
    try:
        event = verify_webhook(SECRET, request.get_data(), request.headers.get("cleat-signature", ""))
    except CleatSignatureError:
        return "", 400

    message = event.data
    if message is not None and not event.is_test:
        # A failed delivery is retried, so the same message id can arrive more
        # than once. Make this idempotent on message.id.
        handle(message.id, message.code, message.body)
    return "", 204

Answer any 2xx within 10 seconds. If your endpoint was down, catch up with list_messages(line_id, after=<the receivedAt of the last message you handled>).

Getting an API key

  1. Create a Cleat account at cleat.so and subscribe a line.
  2. Verify your identity once, when Cleat asks. Until that is done the line still receives and keeps every message, but nothing can be read through the API (HTTP 403, verify_first).
  3. In workspace settings, create an API key. Only the workspace owner can do this. A key belongs to one workspace, and can optionally be limited to named lines and given an expiry date.
  4. Put it in the environment as CLEAT_API_KEY, or pass it to the client. Revoking a key stops it working immediately.

Webhook endpoints are created in the same place, also by the workspace owner, up to five per workspace. The signing secret is shown once.

Limits worth knowing before you build

  • The lines are receive-only. No outbound texts, no outbound calls, no 911.
  • US mobile numbers only.
  • The workspace owner verifies their identity once. Until then, reading messages answers 403 (verify_first); list_lines works regardless.
  • One identity-verified owner per line. Teammates in the workspace share the inbox.
  • Most services that refuse VoIP numbers accept a real mobile line, but nobody can promise that a particular service will accept a particular number.
  • 120 requests per minute per API key. Over that, the API answers 429 — with no Retry-After and no rate-limit headers, so this client backs off on its own (exponential with jitter, three retries by default; set max_retries to change it).
  • A line whose subscription lapses goes to grace: it keeps receiving and storing texts, but reading them answers 402 until it is resubscribed.
  • A line outside a scoped key's allow-list answers 404, exactly like a line in somebody else's workspace. If a line you can see in the dashboard 404s, check the key's scope.
  • Cleat recognises the sending service conservatively, because several services share one short code, so message.service is often None on a perfectly ordinary message. Filter on the sender if you know it.
  • A code read out by an automated call arrives as an ordinary message: transcript in body, the calling number in from_, code filled in. Nothing marks it as a call.

API surface

CleatClient(api_key=None, *, base_url, timeout, max_retries, transport) Sync client. Falls back to CLEAT_API_KEY. Context manager, plus close().
AsyncCleatClient(...) Same arguments and methods, async with, plus aclose().
.list_lines() Every line in the key's workspace, newest first. Released lines included.
.list_messages(line_id, *, after, before, limit) Messages on a line. after and before take a datetime or an ISO 8601 string.
.wait_for_code(line_id, *, since, from_, service, timeout, poll_interval) Polls until a code arrives and returns it as a str. Raises CleatTimeoutError.
.wait_for_message(line_id, ...) The same poll, returning the whole Message — use it when you need body, from_ or the call transcript. require_code=False takes the first matching message whether or not a code was extracted.
verify_webhook(secret, raw_body, header, *, tolerance=300) Module-level. Verifies a delivery and returns a WebhookEvent.
parse_signature_header(header) The pieces of a cleat-signature header, if you need them.

Models are frozen dataclasses: Line, Message, MessageLine, Service, Contact, WebhookEvent. Timestamps are parsed to timezone-aware UTC datetime, with the original string kept alongside as created_at_raw / received_at_raw. Every model keeps the whole decoded payload in .raw, and a field this version does not know about never breaks a parse.

Errors all derive from CleatError: CleatAPIError (with status, message, code, response) and its subclasses BadRequestError (400), AuthenticationError (401), KeyExpiredError (401 key_expired), LineOnHoldError (402), VerificationRequiredError (403 verify_first), NotFoundError (404), RateLimitError (429), plus CleatTimeoutError, CleatConfigurationError, and CleatSignatureError with MalformedSignatureHeaderError / SignatureTimestampError / InvalidSignatureError.

Development

python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest

The tests stub every HTTP call with httpx.MockTransport and open no sockets.

MIT licensed.

Release files for cleatapi 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cleatapi 0.1.0
File Size Uploaded
cleatapi-0.1.0.tar.gz 25.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cleatapi 0.1.0
File Interpreter ABI Platform
cleatapi-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 46.0 kB

Release files / cleatapi-0.1.0.tar.gz

Download URL cleatapi-0.1.0.tar.gz
Size 25.4 kB
Tags Source
SHA-256 checksum
How to use checksums
113cf74467613fcdfc2a11c3cf0a1f380e70b0e3370bfd6695cc00c08e0bffe4
BLAKE2b-256 checksum
How to use checksums
0b06de31614a1a2377bd9b479c621ea407dd00e247f986a0148e33881339ecdd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / cleatapi-0.1.0-py3-none-any.whl

Download URL cleatapi-0.1.0-py3-none-any.whl
Size 20.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2de93a661b6a9af58183f61984c9028ed60c2d51e08edfe25182c174703f2b97
BLAKE2b-256 checksum
How to use checksums
5134f73806f37cb7f4e38afcf726dcd2cb22c2000babb6dc9096f02331049364
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page