Oakring: text messages (SMS)
Send text messages (order ready, appointment reminders, alerts, sign-in codes) from your own toll-free number, and get replies in your app as a webhook. Opt-outs (STOP) are handled for you, and every text is logged with what happened to it: sent, delivered, undelivered, opted out.
US and Canadian phone numbers only, for now.
Quick start
- Get an API key at https://oakstack.dev/dashboard/api-keys. Use a test key (
ok_test_...) while building: texts are checked and logged exactly like real ones, but nothing is sent. - Send:
import { Oakstack } from "oakstack";
const oakstack = new Oakstack();
const text = await oakstack.ring.messages.send({
to: "+16125550123",
body: "Acme: your order #1042 is ready for pickup. Reply STOP to opt out.",
metadata: { orderId: "1042" },
});
// text.status === "queued"; it goes out within seconds.
const detail = await oakstack.ring.messages.get(text.id); // status + every event
- Before sending live (an
ok_live_key), get your number verified (below). That takes a few business days, so start early.
Or with an agent: the MCP tools ring_status, ring_send_text, ring_get_text, and ring_set_reply_hook. With curl:
curl https://oakstack.dev/api/v1/ring/messages \
-H "Authorization: Bearer $OAKSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"+16125550123","body":"Your order is ready. Reply STOP to opt out."}'
Test mode
With an ok_test_ key, every rule that doesn't depend on sending is applied (phone format, length, the hourly limit per phone) and the text is logged, but nothing leaves Oakstack. Delivery is simulated a moment later:
- any number:
delivered +12025550180:undelivered(error 30003: phone off or out of range)+12025550181:undelivered(error 30007: filtered by the carrier as spam)
Test texts work on every plan, including Free, and don't need a number. They count toward a separate monthly test limit (Free 100, Builder 1,000, Studio 5,000), not your real texts.
Your number (toll-free verification)
US carriers block texts from numbers that haven't been verified, so every business texts from its own toll-free number (888, 877, 833, ...) that carriers have reviewed. Oakstack buys the number and submits it for you; you provide the business details:
const number = await oakstack.ring.numbers.create({
name: "Main",
business: {
businessName: "Acme Hobby",
website: "https://acmehobby.com",
businessType: "PRIVATE_PROFIT", // or SOLE_PROPRIETOR, NON_PROFIT, PUBLIC_PROFIT, GOVERNMENT
registrationNumber: "12-3456789", // US: the EIN. Not needed for SOLE_PROPRIETOR.
address: {
street: "1 Main St",
city: "Minneapolis",
state: "MN",
postalCode: "55401",
country: "US",
},
contact: {
firstName: "Sam",
lastName: "Lee",
email: "sam@acmehobby.com",
phone: "+16125550123",
},
useCases: ["ACCOUNT_NOTIFICATIONS"], // also: TWO_FACTOR_AUTHENTICATION, CUSTOMER_CARE, DELIVERY_NOTIFICATIONS, EVENTS, MARKETING, ...
useCaseSummary:
"Order-ready and preorder pickup notices to customers who tick 'Text me' at checkout.",
sampleMessage: "Acme Hobby: your preorder is ready for pickup. Reply STOP to opt out.",
optInType: "WEB_FORM", // or VERBAL, PAPER_FORM, VIA_TEXT, MOBILE_QR_CODE
optInImageUrls: ["https://acmehobby.com/images/checkout-sms-consent.png"],
monthlyVolume: "1,000", // "10", "100", "1,000", "10,000", "100,000"
privacyPolicyUrl: "https://acmehobby.com/privacy",
},
});
number.phoneNumber; // "+18885550123"
number.status; // "verifying"
What reviewers check, and what gets numbers rejected most often:
- Opt-in: how people agree to get texts.
optInImageUrlsmust be public links to a screenshot (or page) showing it, for example the checkbox at checkout and the words next to it ("Text me order updates. Msg & data rates may apply. Reply STOP to opt out."). - The sample message must look like your real texts, name your business, and include opt-out wording ("Reply STOP to opt out."). Oakstack refuses samples without "STOP".
- The website must be live and match the business name.
- EIN: required for companies and nonprofits since February 2026. Sole proprietors can leave it out.
The number is checked automatically every 30 minutes and becomes active by itself, usually in 3 to 5 business days. If it's rejected, rejectionReason says why: fix the details and resubmit within about a week with oakstack.ring.numbers.resubmit(id, { business }). A rejected number that isn't resubmitted is released after 14 days.
Numbers per plan: Free 0 (test mode only), Builder 1, Studio 1. An Extra phone number add-on ($5/month, Billing page) allows one more. Deleting a number releases it; it can't be recovered.
Before your number is approved: the sandbox
On a paid plan you can text your own phones right away, from Oakstack's shared sandbox number. Verify each phone first (it gets a 6-digit code, which counts as one text):
await oakstack.ring.verifiedPhones.add({ phone: "+16125550123" }); // texts a code
await oakstack.ring.verifiedPhones.confirm("+16125550123", { code: "123456" });
await oakstack.ring.messages.send({ to: "+16125550123", body: "It works. Reply STOP to opt out." });
Without an approved number, live texts go from the sandbox number and only to verified phones (Builder 5, Studio 10). Once your number is active, texts go from it automatically.
Who you can text
| Key | Recipients | From |
|---|---|---|
ok_test_ (any plan) |
anyone (nothing is sent) | anything |
ok_live_ on Free |
nobody | |
ok_live_ on Builder/Studio |
anyone who agreed to texts | your approved number |
ok_live_, number pending |
your verified phones only | the sandbox number |
Only text people who agreed to get texts from you, and only about what they agreed to. That's the law (TCPA) and the carriers' rule, and it's what keeps your number working.
oakstack.ring.status() tells you what applies right now, with a nextStep sentence.
What happens to a text
- Accepted (
queued): checked and stored; the call returns. If the phone opted out, it's stored asopted_outand not counted. - Sent (
sent): handed to the carrier, usually within seconds. Throttling or outages are retried for about 2 hours; if it still can't go out it'sfailedand not counted. - Delivered or undelivered: reported by the carrier.
errorCodesays why a text didn't arrive:30003phone off or unreachable,30005number doesn't exist,30006landline,30007filtered as spam.
Statuses: queued, sending, sent, delivered, undelivered, failed, opted_out, canceled, and received (a reply).
Schedule a text up to 30 days ahead with scheduledAt, and cancel it before then with oakstack.ring.messages.cancel(id).
Length and cost (segments)
Texts are billed in segments:
- Plain text (letters, digits, common punctuation): 160 characters in one segment; longer texts are split into 153-character parts.
- One emoji, curly quote (’ “ ”), or accented letter outside the basic set switches the whole text to 70 characters per segment (67 per part).
segments on each text says what it counted. At most 10 segments per text. Tips: use straight quotes, and leave emoji out of long texts.
Monthly limits: Builder 100 segments, Studio 300. Need more? An SMS pack add-on adds 500 segments a month for $10 (Billing page; stack as many as you need). Free can't send live texts. At the limit, sends return usage_limit_reached and nothing is sent. Replies don't count.
Opt-outs (STOP)
When someone replies STOP (or STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT, REVOKE), they're added to your workspace's opt-out list and every later text to them is skipped (opted_out, not counted). The carrier also sends them a confirmation. If they reply START or UNSTOP, they're removed.
await oakstack.ring.optOuts.add({ phone: "+16125550123" }); // e.g. they asked by email
await oakstack.ring.optOuts.list();
await oakstack.ring.optOuts.delete("+16125550123"); // manual entries only
Phones that replied STOP can't be removed by you; only they can opt back in, by texting START.
Put "Reply STOP to opt out." in the first text you send anyone, and every so often after that.
Replies (webhook)
Link a number to an Oakhook endpoint and every text people send to it reaches your app as JSON, with Oakhook's retries and replay:
const ep = await oakstack.hook.endpoints.create({
name: "SMS replies",
forwardUrl: "https://acme.com/api/sms-replies",
});
await oakstack.ring.numbers.update(number.id, { hookEndpointId: ep.id });
Your route receives (verify Oakstack-Signature with the endpoint's signing secret):
{
"object": "ring.inbound_message",
"id": "4f0c...",
"numberId": "9d1e...",
"from": "+16125550123",
"to": "+18885550123",
"body": "Can I pick up tomorrow instead?",
"media": [{ "url": "https://api.twilio.com/...", "contentType": "image/jpeg" }],
"optOut": null,
"receivedAt": "2026-10-07T18:22:10Z"
}
optOut is "stop" or "start" when the reply was an opt-out keyword (already handled; you don't need to do anything). Replies also show in messages.list({ direction: "inbound" }).
Limits that protect your number
- 10 texts an hour to one phone: stops runaway loops before carriers notice. Returns
usage_limit_reached. - Automatic pause: if more than 5% of people reply STOP (once you've sent 40 texts), or carriers filter more than 5% of texts as spam (once you've sent 20), over the last 30 days, live texting pauses (
sending_paused). Queued texts fail without being counted. Test keys keep working. Email support@oakstack.dev to review and resume. - Link shorteners (bit.ly, tinyurl, ...) are refused: carriers filter them as spam. Use full links or a short link on your own domain.
Endpoints
| Method | Path | What |
|---|---|---|
| POST | /api/v1/ring/messages |
send { to, body, from?, metadata?, scheduledAt? } |
| GET | /api/v1/ring/messages |
list (?status=, ?direction=, ?limit=) |
| GET | /api/v1/ring/messages/:id |
one text with events |
| POST | /api/v1/ring/messages/:id/cancel |
cancel a scheduled text |
| POST | /api/v1/ring/numbers |
get a number { name, business, hookEndpointId? } |
| GET | /api/v1/ring/numbers |
list numbers |
| GET | /api/v1/ring/numbers/:id |
one number with its business details |
| PATCH | /api/v1/ring/numbers/:id |
{ name?, hookEndpointId? } |
| POST | /api/v1/ring/numbers/:id/verification |
resubmit a rejected number { business } |
| DELETE | /api/v1/ring/numbers/:id |
release a number |
| GET | /api/v1/ring/opt-outs |
opt-out list |
| POST | /api/v1/ring/opt-outs |
{ phone } |
| DELETE | /api/v1/ring/opt-outs/:phone |
remove a manual opt-out |
| GET | /api/v1/ring/verified-phones |
sandbox phones |
| POST | /api/v1/ring/verified-phones |
{ phone }: texts a code |
| POST | /api/v1/ring/verified-phones/:phone/confirm |
{ code } |
| DELETE | /api/v1/ring/verified-phones/:phone |
remove one |
| GET | /api/v1/ring/status |
what applies right now |
Getting, changing, and deleting numbers, canceling texts, and removing opt-outs need a Full access key. Phone numbers in paths can be written +16125550123 (URL-encoded as %2B16125550123).
Errors: invalid_request (400: the message says which field), not_allowed (403: plan, number not approved, phone not verified), sending_paused (403), usage_limit_reached (429), not_enabled (503: live texting isn't switched on yet; test keys work).