Two calls to pair, then one call to read a code. No SDK, no OAuth dance, one secret to store.
# 1. start pairing — margin.so texts a 6-digit code to that phone curl -X POST https://margin.so/api/v1/pair \ -H 'Content-Type: application/json' \ -d '{"phone":"555-555-5555","agent":"Claude Code"}' # 2. ask the person for the code, send it back, keep the key curl -X POST https://margin.so/api/v1/pair \ -H 'Content-Type: application/json' \ -d '{"phone":"555-555-5555","agent":"Claude Code","code":"481920"}' # -> { "key": "msk_…" } # 3. from now on, one call. it waits until a code arrives. curl "https://margin.so/api/v1/otp?wait=60" \ -H "Authorization: Bearer $KEY" # -> { "code": "552914", "service": "Chase", "text": "Your Chase code is 552914…" }
Steps 1 and 2 happen once, ever. Step 3 is the integration.
Unauthenticated — the texted code is the authentication. Call it twice: once without code to send it, once with it to redeem.
| Field | Required | Notes |
|---|---|---|
| phone | yes | Any US format; 555-555-5555, (555) 555-5555 and +15555555555 all work. |
| agent | no | Your name, shown in the text and in the owner's dashboard. Say who you are. |
| code | second call | The 6 digits the person was texted. |
{ "status": "code_sent", "phone": "•••• 5555", "expires_in": 300, "ask_the_user": "margin.so texted a 6-digit code to •••• 5555. What is it?", "next": "POST the same request again with \"code\": \"<the 6 digits>\".", "capture_ready": true }
ask_the_user is wording you can use verbatim, so you don't have to invent a sentence that might misdescribe what is being approved. capture_ready is false when the account has no phone forwarding to it yet — no code will ever arrive, however long you poll, and saying so is more useful than waiting.
{ "status": "paired", "key": "msk_…", // store this. it does not expire. "key_id": "mrg_…", // what the owner sees and can revoke "otp_url": "https://margin.so/api/v1/otp" }
| Query | Default | Notes |
|---|---|---|
| wait | 0 | Hold the request until a code arrives. Up to 20s per call — ask for more and it tells you to call again rather than dropping the connection. |
| since | 120 | Only consider codes from the last N seconds. Ceiling 600. |
| service | — | Substring match on the service, sender or text. |
curl "https://margin.so/api/v1/otp?wait=60&service=chase" \ -H "Authorization: Bearer $KEY" { "code": "552914", "confidence": "high", "text": "Your Chase verification code is 552914.", "service": "Chase", "age_seconds": 3, "expires_in": 597, "waited": 11 }
Nothing yet? {"code": null, "waiting": true, "hint": "…"}, with a hint saying what to do. Never an error — a code that has not arrived is not a failure.
import os, time, requests BASE = "https://margin.so" def pair(phone, agent="my agent", ask=input): """Run once. Returns a key to store; never expires.""" r = requests.post(f"{BASE}/api/v1/pair", json={"phone": phone, "agent": agent}, timeout=15) r.raise_for_status() code = ask(r.json()["ask_the_user"] + " ") r = requests.post(f"{BASE}/api/v1/pair", json={"phone": phone, "agent": agent, "code": code.strip()}, timeout=15) r.raise_for_status() return r.json()["key"] def get_code(key, service=None, timeout=120): """Call right after triggering the send. Blocks until a code lands.""" started = time.time() while time.time() - started < timeout: r = requests.get(f"{BASE}/api/v1/otp", params={"wait": 20, "since": 120, **({"service": service} if service else {})}, headers={"Authorization": f"Bearer {key}"}, timeout=30) if r.status_code == 401: raise RuntimeError("key revoked — ask the owner to pair again") r.raise_for_status() d = r.json() if d.get("code"): return d["code"] raise TimeoutError("no code arrived")
Trigger the send first, then call get_code. The since window is what stops you picking up the code from a login five minutes ago and burning a passcode that was already spent.
Optional, and needs an OAuth token with hooks:write rather than a paired key — a key that can only read is the right default for an agent.
curl -X POST https://margin.so/api/v1/hooks \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"url": "https://my-agent.example.com/otp"}'
// -> { "webhook": { "id": "wh_…", "secret": "whsec_…" } }
Deliveries carry X-Margin-Signature: t=<ts>,v1=<hmac>, where the HMAC-SHA256 is over "<ts>.<raw body>" with the secret. Verify it and reject anything older than five minutes.
A paired key is a credential secret used directly as a bearer token, which is all most agents need. The same credential also works as a standard OAuth client, for anyone who would rather hold a short-lived token than a long-lived secret.
curl -X POST https://margin.so/oauth/token \
-d grant_type=client_credentials \
-d client_id=$KEY_ID -d client_secret=$KEY
{ "access_token": "mat_…", "expires_in": 3600, "refresh_token": "mrt_…" }
Scopes: otp:read, hooks:write, account:read. A paired key holds otp:read only. GET /api/v1/messages is the list form of /api/v1/otp, and GET /api/v1/me reports whether capture is live. POST /oauth/revoke drops a single token (RFC 7009).
{"error": "...", "error_description": "..."}, always.
| Status | error | What to do |
|---|---|---|
| 401 | invalid_key | Bad or deleted key. Pair again. |
| 401 | revoked_key | The owner revoked it. Stop, and tell them. |
| 402 | account_inactive | Subscription lapsed. Retrying will not help. |
| 404 | pairing_failed | No account on that number — they need to sign up first. |
| 400 | pairing_failed | Wrong or expired code. Three attempts, then start over. |
| 429 | rate_limited | Back off. Use wait= rather than a tight loop. |
What the phone posts to, not something an agent calls. Authenticated with a device token (mdv_…) from the setup pages — sending messages in and reading them out are separate powers.
curl -X POST https://margin.so/api/v1/ingest \ -H "Authorization: Bearer mdv_…" \ -d 'text=Your Acme code is 481920'
The body is read generously — JSON, form-encoded or a bare string, with the message under text, message, body or content. Phone automation tools disagree about all of this, and losing a passcode to a key name would be a self-inflicted outage. Identical text within 90 seconds counts once.