API

Two calls to pair, then one call to read a code. No SDK, no OAuth dance, one secret to store.

The whole integration

# 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.

Base URL https://margin.so · JSON in, JSON out · times are Unix seconds, UTC · US numbers only · capture works on iPhone and Android · a machine-readable summary lives at /llms.txt.

Pairing

POST /api/v1/pair

Unauthenticated — the texted code is the authentication. Call it twice: once without code to send it, once with it to redeem.

FieldRequiredNotes
phoneyesAny US format; 555-555-5555, (555) 555-5555 and +15555555555 all work.
agentnoYour name, shown in the text and in the owner's dashboard. Say who you are.
codesecond callThe 6 digits the person was texted.

First call — send the code

{
  "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.

Second call — redeem it

{
  "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"
}
The text step cannot be skipped. A phone number is not a secret — without it, anyone who knew a customer's number could mint a key that reads the passcodes protecting their bank. Pairing is rate limited to 3 attempts an hour per number.
The key reads codes and nothing else. It appears in the owner's dashboard under the name you gave, with a last-used time, and one click revokes it.

Reading a code

GET /api/v1/otp

QueryDefaultNotes
wait0 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.
since120 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.

Use wait instead of a polling loop. One blocking call is simpler for you and cheaper for us, and it removes the three bugs every hand-written poller has: no timeout, no ceiling, and an interval tight enough to trip the rate limiter (120 requests a minute per key).
Read text, not only code. Extraction is a scored heuristic and reports its confidence — high, low or none. On anything below high, the full message is there and you will read it better than a regex.

A complete agent

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.


Webhooks

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.

Delivery is best effort, once. We POST with a four-second timeout and do not retry — a passcode is dead in ten minutes, so a redelivery two minutes later arrives after the window that matters. Treat webhooks as the fast path and /api/v1/otp?wait= as the reliable one.

OAuth, if you want it

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_…" }
Refresh tokens rotate, and replay revokes the credential. Sending a retired refresh token does not return an error and carry on — the likeliest explanation is that someone else has a copy, so the credential dies and the owner is told. Store the new one before you use it, and never refresh from two processes at once.

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).


Errors

{"error": "...", "error_description": "..."}, always.

StatuserrorWhat to do
401invalid_keyBad or deleted key. Pair again.
401revoked_keyThe owner revoked it. Stop, and tell them.
402account_inactiveSubscription lapsed. Retrying will not help.
404pairing_failedNo account on that number — they need to sign up first.
400pairing_failedWrong or expired code. Three attempts, then start over.
429rate_limitedBack off. Use wait= rather than a tight loop.

Capture endpoint

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.

What a key is worth. Whoever holds one can read the passcodes that unlock the owner's other services. Keep it in the environment, not in a repo; pair once per agent so a single revocation is surgical; and expect every read to appear in their audit log.
Request access Connect a phone