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.
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.
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-Passwordheader. Asked for from the operator out-of-band.400 invalid_input / quote_content_mismatch / quote_expired
Request shape is wrong, or
message/todon'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
transactionfield 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
transactionhash for reconciliation.
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.
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
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.