Stops
stop stopall unsubscribe cancel end quit
Gobo Studios · a DBA of Monterey Skates LLC · Monterey, CA · (831) 264-8797 · gobostudios.com
One endpoint. Another program — a rink’s website, a booking tool, whatever sends your reminders — sends a text that arrives from the number your customers already have in their phone, instead of a shortcode nobody recognises.
POST https://gobophone.app/api/v1/messages
A key is issued in GoboPhone under Settings → API access for programs that need to send texts, by the account holder, and shown once.
The key names the numbers it may send from — it can speak as those and nothing else. Keep it in the environment, never in the repository.
# .env
GOBOPHONE_API_KEY=gpk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GOBOPHONE_FROM=8315550123
The whole integration, in one function:
export async function sendText(to, body) {
const response = await fetch('https://gobophone.app/api/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GOBOPHONE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from: process.env.GOBOPHONE_FROM, to, body }),
});
const result = await response.json();
if (!response.ok) throw new Error(`${result.code}: ${result.error}`);
return result.id; // the carrier's id for this message
}
Numbers are read loosely — (831) 555-0123, 831-555-0123 and 8315550123 are the same number, since non-digits are stripped before anything is compared. Send 10-digit US numbers; don’t add a +1.
Three fields, all required, all strings.
POST /api/v1/messages
Authorization: Bearer gpk_…
Content-Type: application/json
{
"from": "8315550123", // a number written on the key
"to": "8315559876", // who receives it
"body": "Your skate lesson starts at 4pm."
}
202 Accepted
{ "id": "12345678", "status": "sent" }
202 means the carrier accepted it. It does not mean it arrived. Nothing in v1 reports delivery, and messages can be dropped above the carrier without an error reaching anyone — see Before it works for real.
So don’t tell a customer “we’ve texted you” on the strength of a 202 alone. “We’re sending a text to ···9876 — tell us if it doesn’t arrive in a minute” is the honest version, and it’s also the one that doesn’t leave someone staring at a phone.
Every refusal has the same shape, with a code to branch on:
{ "error": "8315559876 has replied STOP to 8315550123 and cannot be texted from it.",
"code": "opted_out" }
Branch on code, never on error — the wording is written for a person reading a log and will change.
| Status | Code | What happened | What to do |
|---|---|---|---|
| 401 | unauthorized | The key is unknown or has been revoked. | Check the environment variable. Keys don’t expire; they’re revoked deliberately. |
| 403 | from_not_allowed | The key may not speak as that number. The message names the ones it can. | Fix from, or tick that number on the key in GoboPhone. |
| 403 | plan_required | The GoboPhone account isn’t on Enterprise. | Nothing to fix in code. Existing keys stop sending when a plan is dropped. |
| 400 | missing_from / missing_to / missing_body | A field is absent or blank. | A bug on the calling side — these never depend on who’s being texted. |
| 400 | body_too_long | Over 160 bytes. The message says how many it was. | Shorten it. It is refused rather than split. |
| 409 | opted_out | This person replied STOP to this number. | Record it against the customer and stop offering texts. Only they can undo it. |
| 502 | carrier_refused | voip.ms rejected it. Its own words are in error. | Safe to retry once, after a pause. Log the message — it’s the only account of what went wrong. |
One message, 160 bytes of UTF-8. Measured against the live carrier, not read off a spec.
Bytes, not characters — and that distinction bites in exactly one place. A curly apostrophe costs three bytes, an em dash three, an emoji four. A 158-character message with three smart quotes in it does not fit, and body.length will tell you it does.
| Message | Characters | Bytes | |
|---|---|---|---|
| Monterey Skates: your lesson with Coach Dana starts at 4pm. Rink door code 1994. | 80 | 80 | fits |
| Sign in to Monterey Skates: https://montereyskates.com/account/session/8fK2pQ (20 min) | 86 | 86 | fits |
| Monterey Skates — sign Emma’s waiver before Saturday’s session: https://montereyskates.com/wi/9f2a | 98 | 104 | fits, 6 bytes wider than it looks |
Count it the same way on your side before you send:
const fits = Buffer.byteLength(body, 'utf8') <= 160; // Node
const fits = new TextEncoder().encode(body).length <= 160; // anywhere
Why it’s refused, not split: a link broken across two texts is a login that doesn’t work, and the second half reads like spam arriving on its own. GoboPhone would rather hand back an error your code can see than deliver two useless messages your customer can’t use.
If a link won’t fit, shorten the link rather than the sentence — but use your own domain. Public shorteners are among the most heavily filtered things that can appear in a text.
GoboPhone keeps the list. Your program is told, and can’t override it.
A reply of STOP to one of the account’s numbers is recorded within five minutes, whoever sent the message that prompted it — this API, the GoboPhone dashboard, or a person holding the phone. The next send to that person from that number is refused with 409 opted_out.
There’s no callback and no list to poll. You find out on the next attempt — which for a magic link is the moment someone asks for one, so there’s always a person there to be told, and a good place to say “this number has opted out of our texts; here’s another way in”.
The whole message, case-insensitively, ignoring trailing punctuation. “Stop by after four” silences nobody.
stop stopall unsubscribe cancel end quit
start unstop yes subscribe
Not yours to clear. Opting out is per number — silencing the rink doesn’t silence a different line on the same account — and nothing in this API can undo one. Neither can the rink. Only the person who sent STOP, by replying START.
So treat a 409 as durable: record it against the customer, stop offering them the text option, and offer email instead. Retrying tomorrow will get the same answer.
The endpoint working and a text arriving are two different milestones.
US carriers filter business text traffic sent to ordinary phone numbers. Unregistered traffic is dropped silently — above voip.ms, after this API has already answered 202. Nothing appears in any log at either end. A magic link that never lands looks, from the rink’s side, like a login that’s simply broken.
That registration is 10DLC, it is done in the voip.ms account, and it costs about $10 a month for the campaign: 10DLC Business Text Messaging Regulations and Requirements.
A message with no link, sent to a phone you’re holding, is a good test of the plumbing — key, endpoint, carrier, handset. It is not a test of deliverability, because the link is the part most likely to be filtered. Both are worth doing; don’t let the first stand in for the second.
Stated plainly, because each of these is something you would otherwise build against and discover later.
202 is the carrier’s acceptance and the end of the story. If you need to know a text arrived, the only signal available today is the customer acting on it.
Nothing here stops a loop texting the same person forty times. voip.ms will take roughly one message every ten seconds and bill for each. Put your own guard on the calling side: one send per person per minute is plenty for sign-in links, and it is the abuse shape that actually happens.
Replies land in the GoboPhone Inbox, not in a webhook. Automated sends are recorded there too, folded behind a Show automated filter, so the rink can answer “we sent you four links, and you replied STOP in between”.
No batch endpoint. Send them one at a time, paced by your own limit.
Keys are issued by the account holder in GoboPhone once the account is on Enterprise. An existing key stops sending if the plan is dropped — it is not revoked, so it starts working again if the plan comes back.