API

Two endpoints. That's it.

Quote a message, pay in USDC over x402, send via Sinch. Quotes expire in 10 minutes. Pricing is $0.0500 per SMS segment plus actual Gemini moderation cost. Network: eip155:84532.

01 — Quote

POST /api/quote

Submit a message and destination. The server runs Gemini moderation, and on approval returns a signed quote_token bound to that exact message + destination, plus the total USD price (SMS + moderation cost) you'll pay on the next call.

Request

POST /api/quote
Content-Type: application/json

{
  "message": "Your order has shipped — track at example.com/t/123",
  "to": "+13012576365"
}

Response (200, approved)

{
  "approved": true,
  "category": "ok",
  "confidence": 0.95,
  "segments": 1,
  "sms_price_usd": 0.05,
  "moderation_cost_usd": 0.000626,
  "price_usd": 0.050626,
  "moderation_usage": { "model": "gemini-2.5-flash", "input_tokens": 412, "output_tokens": 144, ... },
  "to_normalized": "+13012576365",
  "quote_token": "eyJ2IjoxLCJjb250...",
  "expires_in_seconds": 600,
  "next_step": {
    "method": "POST",
    "path": "/api/send",
    "body": { "message": "...", "to": "+13012576365", "quote_token": "..." },
    "payment": { "protocol": "x402", "network": "eip155:84532",
                 "amount_usd": 0.050626, "recipient": "0x85F37873f97afa61263A27107f2e7b0B672d7CFF" }
  }
}

Response (422, rejected)

{
  "approved": false,
  "category": "spam" | "political" | "shaft" | "phishing" | "...",
  "confidence": 0.93,
  "reason": "Plain-text explanation of why Gemini blocked it."
}

price_usd is the total you'll be charged on-chain (SMS segment cost + actual Gemini token cost). Use it as the x402 amount. quote_token is HMAC-signed; the server rejects mismatched message/to on /api/send.

02 — Send

POST /api/send

Two-pass x402 flow. First call returns a 402 Payment Required challenge. Sign the EIP-3009 USDC transferWithAuthorization for the quoted amount, then re-POST with a PAYMENT-SIGNATURE header (or X-PAYMENT for v1 clients). Server runs verify → settle on-chain → and only then dispatches the SMS.

Authorization

/api/send is gated by a shared PAY_PASSWORD set on the operator's server. Pass it as the X-Pay-Password header on every /api/send call (the wrapped fetch carries it through both the unpaid 402 and the signed retry). Wrong or missing password ⇒ 401.

First request (no signature)

POST /api/send
Content-Type: application/json
X-Pay-Password: <shared password>

{
  "message": "Your order has shipped — track at example.com/t/123",
  "to": "+13012576365",
  "quote_token": "eyJ2IjoxLCJjb250..."
}

First response (402)

HTTP/1.1 402 Payment Required
PAYMENT-REQUIRED: <base64 of the x402 challenge body>
Content-Type: application/json

{
  "error": "payment_required",
  "protocol": "x402",
  "price_usd": 0.050626,
  "how_to_pay": "Resubmit POST /api/send with a valid X-PAYMENT header signed for the requested amount."
}

Decode the PAYMENT-REQUIRED header to get the accepts array (network, asset, amount, payTo, scheme, extra). Most agents use a wrapper like @x402/fetch that handles this automatically.

Second request (with signature)

POST /api/send
Content-Type: application/json
X-Pay-Password: <shared password>
PAYMENT-SIGNATURE: <base64 of the signed PaymentPayload>

{ "message": "...", "to": "+13012576365", "quote_token": "..." }

Second response (200, success)

HTTP/1.1 200 OK
PAYMENT-RESPONSE: <base64 of the SettleResponse>
Content-Type: application/json

{
  "sent": true,
  "message_id": "01KR2MA4S2M4AMVGJTN1T7F87T",
  "status": "queued",
  "to": "+13012576365",
  "segments": 1,
  "price_usd": 0.050626,
  "paid_by": "0x85f37873f97afa61263a27107f2e7b0b672d7cff",
  "transaction": "0xb53d7eca..."
}

Failures

  • 401 unauthorized

    Missing or wrong X-Pay-Password header. Asked for from the operator out-of-band.

  • 400 invalid_input / quote_content_mismatch / quote_expired

    Request shape is wrong, or message/to don't match what the quote was issued for.

  • 402 payment_verify_failed

    Facilitator rejected the signature: invalid signer, insufficient USDC, expired authorization, etc. Common reason: invalid_exact_evm_insufficient_balance — fund the wallet at faucet.circle.com.

  • 402 payment_settlement_failed

    Verify passed but the on-chain settle failed. Either facilitator timeout, chain error, or rejected by USDC contract. SMS is not sent. Funds may or may not have moved (check the transaction field on the detail).

  • 422 rejected

    Gemini moderation blocked the message during the original quote.

  • 502 sinch_send_failed_after_payment

    Rare. Settlement succeeded on-chain but Sinch then rejected the send. Operator should refund. Response includes the transaction hash for reconciliation.

03 — Pricing

Per message, no subscription.

  • SMS

    $0.0500 per segment. 160 GSM-7 chars or 70 UCS-2 chars per segment; longer messages bill by segment count.

  • Moderation

    Pass-through Gemini token cost (varies, typically <$0.001 per message on gemini-2.5-flash).

  • Total

    price_usd = sms_price_usd + moderation_cost_usd, rounded to 6 dp (USDC decimal precision). This is the amount the wallet signs for and the on-chain settle moves.

04 — Outbound message format

Every SMS gets an opt-out line.

For US 10DLC compliance, the gateway appends Reply STOP to opt out on a new line to every outgoing SMS. This is added by the backend after moderation and quoting — you don't need to include it yourself. The operator absorbs the extra ~22 chars; pricing is still computed from the message you submit.

You submit:  "Your order has shipped — track at example.com/t/123"

Recipient gets:
  Your order has shipped — track at example.com/t/123
  Reply STOP to opt out
05 — Phone-number normalization

Any common format works.

The server accepts these and returns the canonical E.164 in to_normalized:

3012576365         → +13012576365
301-257-6365       → +13012576365
(301) 257-6365     → +13012576365
+1 (301) 257-6365  → +13012576365
13012576365        → +13012576365
+13012576365       → +13012576365 (already E.164)
+442071838750      → +442071838750 (international)

Use to_normalized from the quote response when calling /api/send — that's the value the quote_token is bound to.