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

  1. 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.
  2. 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
  1. 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:

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:

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

  1. Accepted (queued): checked and stored; the call returns. If the phone opted out, it's stored as opted_out and not counted.
  2. 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's failed and not counted.
  3. Delivered or undelivered: reported by the carrier. errorCode says why a text didn't arrive: 30003 phone off or unreachable, 30005 number doesn't exist, 30006 landline, 30007 filtered 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:

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

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