A Go backend can send SMS through POST /api/v1/sms/messages on api.illyvoip.com using the standard library. The important part is keeping the business operation stable across timeouts and restarts, then tracking queued messages separately from delivery.
This guide uses Go 1.24-compatible APIs and was compiled/tested with the locally verified Go toolchain on October 9, 2026. It is a custom REST client, not an official IllyVoIP Go SDK. Tests used local httptest servers; no live SMS was sent.

1. Prepare the account and server
You need SMS API access, an available Sender ID, sufficient balance and permission to message the intended recipients/destinations. Use the default IllyVoIP sender until a custom name is approved and available for that account. Check destination availability separately from number syntax.
Keep the API key in a server secret store, for example an environment value read only by the SMS worker. Authenticate with X-Api-Key. Never send that key to the browser or include it in logs, source code or a client-visible error.
Create a Go module with go mod init example.com/illysms. The client below needs only the standard library. The official net/http reference covers the reused client, timeouts and request behavior.
2. Store an operation before its first attempt
Create one business notification record with a unique Idempotency-Key and a frozen JSON body. Use 8–128 letters, digits, dots, colons, underscores or dashes in the key. Persist both values transactionally before a worker sends the request.
The JSON fields are from, to and message; to is an array even for one recipient. Use a JSON encoder so quotes, line breaks and Unicode text are encoded correctly. Do not build JSON by concatenating a message into a string.
For example, the logical notification “appointment 812 reminder version 1” gets one operation key. After a timeout, reload that key and exact payload. Changing the text or recipient creates a different business decision; do not silently give an uncertain send a new key and try again.
3. Add the reusable Go client
Save this as sms.go in the module. Build one Client for the worker/service lifetime. Redirects are not followed, request time is bounded, response bodies are closed and response bytes are bounded before decoding.
package illysms
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"time"
)
type Message struct {
From string `json:"from"`
To []string `json:"to"`
Message string `json:"message"`
}
// Persist both values with the business notification before the first attempt.
// On recovery, load these exact values; do not generate a replacement key.
type Operation struct {
Key string
Payload []byte
}
type Result struct {
Status string `json:"status"`
IDs []string `json:"queued_message_ids"`
Count int `json:"queued_count"`
TotalCost json.Number `json:"total_cost"`
Invalid []struct {
Number string `json:"number"`
Reason string `json:"reason"`
Code string `json:"error_code"`
} `json:"invalid_numbers"`
Duplicates []string `json:"duplicates_removed"`
Encoding string `json:"encoding"`
Segments int `json:"segments_per_recipient"`
Replayed bool `json:"replayed"`
Conversion json.RawMessage `json:"text_conversion"`
}
type Receipt struct {
HTTPStatus int
Body []byte
Result Result
}
type Client struct {
http *http.Client
key string
endpoint string
}
var validKey = regexp.MustCompile(`^[A-Za-z0-9:._-]{8,128}$`)
func NewClient(apiKey string) *Client {
return &Client{key: apiKey,
endpoint: "https://api.illyvoip.com/api/v1/sms/messages",
http: &http.Client{Timeout: 20 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
func Freeze(key string, message Message) (Operation, error) {
if !validKey.MatchString(key) || message.From == "" ||
message.Message == "" || len(message.To) == 0 {
return Operation{}, errors.New("invalid local operation")
}
payload, err := json.Marshal(message)
return Operation{Key: key, Payload: payload}, err
}
func (c *Client) Send(ctx context.Context, op Operation) (Receipt, error) {
var receipt Receipt
if c.key == "" || !validKey.MatchString(op.Key) || !json.Valid(op.Payload) {
return receipt, errors.New("invalid key or stored payload; not sent")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint, bytes.NewReader(op.Payload))
if err != nil {
return receipt, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Api-Key", c.key)
req.Header.Set("Idempotency-Key", op.Key)
response, err := c.http.Do(req)
if err != nil {
return receipt, fmt.Errorf("send result uncertain; reconcile stored operation: %w", err)
}
defer response.Body.Close()
receipt.HTTPStatus = response.StatusCode
const maxBody = 2 << 20
body, err := io.ReadAll(io.LimitReader(response.Body, maxBody+1))
receipt.Body = body
if err != nil || len(body) > maxBody {
return receipt, errors.New("response incomplete or oversized; reconcile before retry")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return receipt, fmt.Errorf("API returned HTTP %d; inspect stored response", response.StatusCode)
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&receipt.Result); err != nil {
return receipt, errors.New("invalid response; acceptance uncertain")
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return receipt, errors.New("trailing response data; acceptance uncertain")
}
r := receipt.Result
if r.Status != "success" || r.Count < 1 || r.Count != len(r.IDs) {
return receipt, errors.New("unexpected result; reconcile before retry")
}
return receipt, nil
}
Keep Receipt.Body in restricted operational storage when needed for reconciliation; it can contain recipient or message-related information. Do not print it, the operation payload or the API key in a public log. Store monetary values accurately rather than converting them to binary floating-point balances in your application.
4. Call the client from a durable worker
Prepare an operation using Freeze, commit its key/payload and business reference to your database, then let a worker load it and call Send. The following fragment belongs inside your worker after that commit; its database helpers are your application’s persistence layer, not part of the API:
// Construct this once, using a server-held secret.
client := illysms.NewClient(apiKey)
// Load Key and Payload from the committed notification operation.
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
receipt, err := client.Send(ctx, storedOperation)
// Persist receipt.HTTPStatus, restricted receipt.Body and the outcome.
// If err != nil, retain the operation for classification/reconciliation.
// Do not replace its key or immediately create another notification.
Claim each operation for one worker at a time. Use bounded concurrency and respect account/rate limits; do not launch an unbounded goroutine per recipient. A caller’s cancelled context ends the local wait, not necessarily a request that the server already accepted.
5. Interpret accepted, rejected and duplicated recipients
A successful response means accepted recipients were queued. Store queued_message_ids and queued_count, inspect invalid_numbers and duplicates_removed, and retain the original recipient list.
The message IDs follow accepted recipients after normalization, duplicate removal and validation. The invalid-number list is separate; do not zip either list against every original input position. Start with one recipient per business operation if that makes your application’s correlation unambiguous.
total_cost is the amount reserved for accepted recipients, not proof of final delivery. Encoding, segment and destination-specific text_conversion information also matter. Review conversion expectations before adopting a template that contains characters whose exact form must be preserved.
6. Classify failures without duplicating paid sends
- Validation or authentication error: inspect the restricted error response and fix the identified issue. Do not retry unchanged invalid data in a loop.
- Partial acceptance: preserve accepted message IDs. Do not resubmit the whole original recipient list with a new key to retry only the rejected recipients.
- 429 or temporary server error: use bounded scheduling and any documented retry guidance, with the same stored identity for an identical attempt.
- Timeout, cancellation, broken connection or malformed response: treat acceptance as uncertain until reconciled. A local error is not proof that nothing was queued.
- Idempotency conflict: compare the stored key and payload. Do not rotate the key to hide a disagreement.
The client adds no application retry loop. Go’s HTTP transport can nevertheless replay eligible requests on some connection failures, including requests carrying an Idempotency-Key when their body is replayable. This is another reason to preserve the same logical operation. Do not promise exactly one wire transmission or indefinite deduplication.
7. Track delivery and test recovery
Use the SMS API delivery guide to poll the documented status endpoint or process verified delivery events. Store transitions idempotently, tolerate duplicate/out-of-order events and distinguish queued, submitted, delivered and failed states.
Before a live send, test with a local HTTP server: correct JSON/headers, partial acceptance, redirects, 429, malformed or oversized responses, a response lost after request receipt, cancellation and reloading the same operation. Go’s httptest package supplies local test servers. A test server passing is not provider delivery acceptance.
For the first authorized live check, use one recipient you control, inspect the exact text received and correlate its message ID and status. Normal charges apply. Do not use real recipients to exercise your retry failure cases.