textbee Logotextbee.dev
Plans from $9.99/mo.View Plans

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.

send.sh
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.

webhook-test.sh
# 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.

poll.sh
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
done

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

errors.sh
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 ;;
esac

Honest 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