Send and receive SMS in curl and shell with a REST API
Published and updated
You need an API key and one Android phone with a SIM registered as a device. From curl and shell you POST to /gateway/send-sms with the x-api-key header, receive replies through a signed webhook or by polling /gateway/messages with a cursor, and the phone sends from its own number. The Pro plan, up to 5,000 messages for $9.99 a month, with no per message fee.
Prerequisites
- A textbee account with an API key from the dashboard, kept in the TEXTBEE_API_KEY environment variable and never in source control.
- An Android phone with a SIM, registered as a device on that account. The API picks your default device, so the code below names none.
- curl and a POSIX shell. The poll and error samples also use jq to read JSON, and the webhook test uses openssl to sign a payload.
Send an SMS
One POST to the account-level endpoint. The phone registered on your account sends the message over its SIM and the response carries the batch id to follow.
BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}"
curl -sS -X POST "$BASE_URL/gateway/send-sms" \
-H "x-api-key: $TEXTBEE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}'Receive SMS with a webhook
textbee POSTs each event to your URL and signs the raw body with HMAC-SHA256 using the secret you set, sent in the X-Signature header. Verify over the exact bytes, deduplicate on idempotencyKey, and answer 200 quickly.
# Post a signed test event to your own endpoint, the way textbee does.
BODY='{"smsId":"66b1f2c3a4d5e6f7a8b9c0d1","message":"Hello","deviceId":"664a9b8cd0e1f2a3b4c5d6e7","webhookSubscriptionId":"664a9b8cd0e1f2a3b4c5d6e8","webhookEvent":"MESSAGE_RECEIVED","idempotencyKey":"8f7e6d5c-4b3a-2c1d-0e9f-8a7b6c5d4e3f","sender":"+12015550123","receivedAt":"2026-09-05T10:00:00.000Z"}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$TEXTBEE_WEBHOOK_SECRET" | sed 's/^.* //')
curl -sS -o /dev/null -w '%{http_code}\n' -X POST "${WEBHOOK_URL:-http://localhost:3000/}" \
-H "Content-Type: application/json" \
-H "X-Signature: $SIGNATURE" \
-d "$BODY"Poll for new messages with a cursor
The pull option. Ask for received messages in ascending order from a start time, then follow meta.nextCursor until meta.hasMore is false. Store the last cursor and resume from it on the next poll, so nothing is missed or read twice.
BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}"
QUERY="direction=received&order=asc&limit=50&from=2026-09-01T00:00:00Z"
CURSOR=""
while :; do
PAGE=$(curl -sS "$BASE_URL/gateway/messages?$QUERY${CURSOR:+&cursor=$CURSOR}" -H "x-api-key: $TEXTBEE_API_KEY")
echo "$PAGE" | jq -r '.data[] | "\(._id) \(.sender) \(.message)"'
[ "$(echo "$PAGE" | jq -r '.meta.hasMore')" = "true" ] || break
CURSOR=$(echo "$PAGE" | jq -r '.meta.nextCursor') # store this to resume the next poll
doneHandle errors
A 401 means the key is missing or revoked, a 400 means the request or the device state was rejected, and a 429 means a plan or batch limit was hit. The body carries a message field that says which. Retry only the 429, once, after a pause.
BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}"
# --retry repeats only on transient statuses such as 429; a 400 or 401 fails at once.
STATUS=$(curl -sS -o response.json -w '%{http_code}' --retry 1 --retry-delay 2 -X POST "$BASE_URL/gateway/send-sms" \
-H "x-api-key: $TEXTBEE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"recipients": ["+12015550123"], "message": "Hello from textbee"}')
case "$STATUS" in
200) jq -r '.data.smsBatchId' response.json ;;
401) echo "API key rejected: $(jq -r .message response.json)" >&2; exit 1 ;;
400) echo "Request rejected: $(jq -r .message response.json)" >&2; exit 1 ;;
*) echo "HTTP $STATUS: $(jq -r .message response.json)" >&2; exit 1 ;;
esacHonest limits
One phone sends roughly 10 to 15 messages a minute, so a large blast takes time to drain. Carriers can filter bulk patterns on consumer SIMs. Marketing messages still need consent from the recipient under the local rules.
Frequently asked questions
Can I send SMS from a cron job or a shell script?
Yes. The send sample is one curl call. Put the key in an environment file the script sources, and the phone sends the message.
How do I receive messages in a shell script?
Poll GET /gateway/messages with the cursor sample on a schedule. A webhook needs a listening server, which is a job for one of the other languages; the shell sample here signs and posts a test event to that server.
What does the error sample retry?
curl --retry repeats only on transient statuses such as 429. A 400 or 401 returns at once, and the case statement prints the message field from the response body.
Read next
- How to Send SMS Programmatically with textbee: A Complete Guide
- textbee.dev SMS Gateway Quickstart
- API reference
- OTP and verification guides
- SMS gateway for the United States
- Webhook, defined
- E.164 phone number format, defined
- Send and receive SMS in Python with a REST API
- Send and receive SMS in JavaScript and TypeScript with a REST API
- All languages