How to Build a Real-Time SMS Status Tracking and Logging Using Go and Twilio

April 24, 2025
Written by
David Fagbuyiro
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Build a Real-Time SMS Status Tracking and Logging Using Go and Twilio

Tracking an SMS' status in real-time is essential for applications that rely on timely notifications, as it ensures messages reach their intended recipients or trigger follow-ups when delivery fails.

This tutorial will walk you through creating a simplistic SMS status tracking and logging system using Go and Twilio. Because Twilio provides reliable SMS and status callback functionality, while Go’s concurrency and efficiency make it ideal for handling real-time data.

So, specifically, in this tutorial, you’ll learn to set up a Go server, integrate Twilio’s Programmable Messaging API, handle status callbacks for real-time tracking, and implement a logging system to record each SMS event.

We’ll cover everything from initial setup and sending messages to building an endpoint for receiving status updates. By the end, you’ll have a solution that can be deployed in production, giving you a solid foundation in SMS tracking and logging for your applications.

Prerequisites

Before you begin, ensure you have the following:

Overview of Twilio's SMS status callbacks

Twilio's SMS status callbacks allow developers to track the lifecycle of an SMS message in real time. When a message is sent, Twilio can notify your server about status changes such as queued, sent, delivered, or undelivered. These updates help you understand whether messages are successfully reaching recipients or encountering delivery issues.

Notifications are sent via HTTP POST request webhooks to a URL which you define in your application. If you're not familiar with the term, a webhook is a way for one application to send real-time data to another application over HTTP when a specific event occurs.

This feature is essential for applications that rely on delivery assurance, auditing, message tracking, or user notifications. For example, an e-commerce platform might use it to confirm that order updates were delivered to customers, or a healthcare app might use it to ensure appointment reminders reached patients.

To use status callbacks, you simply provide a status callback URL when sending an SMS through Twilio. As the message moves through different stages, Twilio automatically POSTs structured data including the message SID, message status, and recipient number to that URL.

For more details on how status callbacks work, including all available status values and webhook payload examples, refer to Twilio’s official documentation.

Create a new Go application

Creating the project involves initializing a new Go module using the go mod init command. This sets up the foundational structure for your application by creating a go.mod file, which defines the module’s name and tracks its dependencies. It’s how Go knows where your project starts and what external packages it will rely on.

Installing dependencies means that any third-party packages your application imports are downloaded and properly tracked. This happens when you run go get or go build, or run your program, prompting Go to fetch the necessary packages and record them in the go.mod and go.sum files.

Run the following commands to create a project directory, navigate into it, and install the required dependencies:

mkdir sms-status-tracking-and-logging && cd sms-status-tracking-and-logging
go get github.com/twilio/twilio-go github.com/joho/godotenv github.com/gocarina/gocsv

The dependencies are:

Then, initialize a new Go module by running the following in your terminal:

go mod init sms-status-tracking-and-logging

Create the environment variables

To simplify storing the application's configuration information, you should use environment variables. These will be stored in an .env file and loaded into the environment using GoDotEnv.

Start by creating a .env file in the root of your project and add the following to it:

CSV_FILE=data.csv
TWILIO_ACCOUNT_SID=<your_account_sid>
TWILIO_AUTH_TOKEN=<your_auth_token>
TWILIO_PHONE_NUMBER=<your_twilio_number>
RECEIVER_PHONE_NUMBER=<your_cell_mobile_number>
STATUS_CALLBACK_URL=<ngrok_forwarding_url>/sms

CSV_FILE is the path to the CSV file where webhook status information will be logged. For a simple example, it's easier than interacting with a database.

The TWILIO_ prefixed variables are your Twilio credentials and phone number. RECEIVER_PHONE_NUMBER will store your phone number, so replace <your_cell_mobile_number> with your mobile/cell phone number in E.164 format.

Finally, the STATUS_CALLBACK_URL variable is the public URL where Twilio will send SMS status updates (e.g., queued, delivered, and undelivered).

Create the CSV template

Next up, create a new file named data.csv in the project's top-level directory. Then, in the file, paste the code below.

Message_SID,Status,To,From,Body

Make sure you add a trailing new line at the end of the CSV file.

Retrieve your Twilio access token

Next, log in to your Twilio Console to retrieve your Account SID, Auth Token, and Twilio phone number from the Account Info panel, as you can see in the screenshot below.

Screenshot of Twilio account info showing Account SID, Auth Token, phone number, and API Keys section.

Then, use them to replace <your_twilio_account_sid>, <your_twilio_auth_token>, and <your_twilio_phone_number>, respectively, in the .env file.

These credentials are necessary because they authenticate your application with Twilio's API, allowing it to send and receive SMS messages securely. The Twilio phone number acts as your application's sender ID, enabling it to interact with real mobile numbers and receive message status updates through webhooks. Without these, your Go application won’t be able to connect to Twilio’s services or handle SMS events properly.

Build the real-time SMS tracking system with Go

Now, let's set up a basic Go application that listens for incoming status updates from Twilio and initiates SMS messages with the callbacks registered.

Set up a basic Go server to handle HTTP requests

To begin, you'll need a simple HTTP server that can handle POST requests. This server will act as the endpoint for receiving Twilio's status updates.

Create a file named main.go inside the root of your project directory. Once you've created main.go, add the provided server and webhook-handling code, below, to the file.

package main

import (
	"fmt"
	"log"
	"net/http"
	"io"
	"os"
	"github.com/gocarina/gocsv"
	"github.com/joho/godotenv"
	"github.com/twilio/twilio-go"
	openapi "github.com/twilio/twilio-go/rest/api/v2010"
)

func main() {
	http.HandleFunc("/sms", statusHandler)
	http.HandleFunc("/send-sms", sendSMSHandler)
	http.HandleFunc("/view-sms-status", viewSMSStatusHandler)
	fmt.Println("Server started at :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

The code creates a new request multiplexer (Mux) listening on port 8080 with three routes:

  • /sms: This route receives the webhook request from Twilio and writes the information to a CSV file.
  • /send-sms: This route receives a POST request containing the message to send, and sends an SMS with that information.
  • /view-sms-status: This route retrieves all of the webhook request data from the CSV file and renders it in a table for easy viewing.

The three routes will be handled by statusHandler(), sendSMSHandler(), and viewSMSStatusHandler(), respectively, which we'll define shortly.

Configure the Go application to receive status updates from Twilio

Twilio sends status callbacks as x-www-form-urlencoded POST requests to the URL you specify. These callbacks provide important real-time data about the status of SMS messages your application sends.

To process these callbacks, your Go application must be able to parse the incoming data and respond accordingly. This is typically done by setting up a route that listens for POST requests on a specific endpoint and extracts the relevant information from them.

The data sent by Twilio usually includes fields such as the message's SID, a unique identifier for the message, the recipient’s phone number, your Twilio phone number, the current delivery status (sent or delivered), and, optionally, an error message if something went wrong. These fields help you track each message's delivery lifecycle, confirm successful deliveries, and identify any issues that may have occurred.

To handle this in Go, define a function that reads and logs this information for further processing, by adding the following code to the end of main.go.

func statusHandler(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		log.Printf("Error parsing form: %v", err)
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	messageSid := r.FormValue("MessageSid")
	to := r.FormValue("To")
	from := r.FormValue("From")
	status := r.FormValue("MessageStatus")
	errorMsg := r.FormValue("ErrorMessage")
	log.Printf(
		"SMS Status Update - SID: %s, From: %s, To: %s, Status: %s, Error: %s",
		messageSid, 
		from, 
		to, 
		status, 
		errorMsg,
	)
 
	if err := writeStatusData(os.Getenv("CSV_FILE"), []string{messageSid, from, to, status, errorMsg}); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
    }
	w.WriteHeader(http.StatusOK)
}

func writeStatusData(filename string, record []string) error {
	f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm)
	if err != nil {
		return err
	}
	defer f.Close()
	writer := csv.NewWriter(f)
	err = writer.Write(record)
	if err != nil {
		return err
	}
	writer.Flush()
	return nil
}

statusHandler() retrieves several fields from the webhook POST request and writes them to the CSV file by calling writeStatusData(). If the data is successfully written, then an HTTP 200 OK status code is returned. Otherwise, an HTTP 400 Bad Request status code is returned. writeStatusData() just opens the application's CSV file and appends the information to the end of it, and closes the file.

Add a function to send SMS with Twilio

Next, let's add a function that sends an SMS to the user's phone number using the Twilio Programmable Messaging API. To do this, add the following function to the main.go file.

func sendSMSHandler(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		log.Printf("Error parsing form: %v", err)
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	if err := sendSMS(r.FormValue("To"), r.FormValue("Body")); err != nil {
		log.Printf("Failed to send message: %v", err)
		http.Error(w, "Failed to send message", http.StatusBadRequest)
		return
	}
}

func sendSMS(to, body, callbackURL string) {
	client := twilio.NewRestClient()
	params := &openapi.CreateMessageParams{}
	params.SetTo(to)
	params.SetFrom(os.Getenv("TWILIO_PHONE_NUMBER"))
	params.SetBody(body)
	params.SetStatusCallback(callbackURL)
	resp, err := client.Api.CreateMessage(params)
	if err != nil {
		return err
	}
	log.Printf("Message sent with SID: %s", *resp.Sid)
}

sendSMSHandler() retrieves the SMS data from the request and passes it to sendSMS(), which initialises a Twilio Rest Client object to send the SMS using Twilio's Programmable Messaging API. If the SMS fails to send, the reason why is logged. If the message was sent, the newly created message's SID is printed out.

Feel free to refactor the code to return other details returned from the API call.

View SMS Status

Next, it's time to implement the ability to view the logged webhook status updates. To do that, first, add the following code to the end of main.go.

type StatusRecord struct {
	MessageSID string `csv:"Message_SID"`
	Status     string `csv:"Status"`
	To         string `csv:"To"`
	From       string `csv:"From"`
}

type TemplateData struct {
	StatusList []*StatusRecord
}

func viewSMSStatusHandler(w http.ResponseWriter, r *http.Request) {
	data, err := readCSVFile(os.Getenv("CSV_FILE"))
	if err != nil {
		fmt.Println("Error reading file:", err)
		return
	}
	
	log.Println(data)
	files := []string{
		"./templates/base.tmpl",
		"./templates/view-status.tmpl",
	}
	
	ts, err := template.ParseFiles(files...)
	if err != nil {
		log.Printf("error parsing templates: %v", err)
		http.Error(w, "error parsing templates", http.StatusBadRequest)
		return
	}

	err = ts.ExecuteTemplate(w, "base", TemplateData{StatusList: data})
	if err != nil {
		log.Printf("error rendering templates: %v", err)
		http.Error(w, "error rendering templates", http.StatusBadRequest)
		return
	}
}

func readCSVFile(filename string) ([]*StatusRecord, error) {
	f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, os.ModePerm)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	statusRecords := []*StatusRecord{}
	if err := gocsv.UnmarshalFile(f, &statusRecords); err != nil {
		return nil, err
	}
	return statusRecords, nil
}

Working from top to bottom, the code defines two structs. The first models the webhook status information stored in the CSV file. Note the JSON tags for each field. These match the respective columns in the header row of the CSV file which you created earlier. The second struct stores a list of the first, and is passed, later, to render the status update template.

Then, comes viewSMSStatusHandler(). This reads the status updates from the application's CSV file by calling readCSVFile(), then renders the view-status.tmpl Go template with the retrieved status information, rendering it in a table for easy viewing. Finally, readCSVFile() is defined which retrieves and returns any records from the CSV file, minus the header record.

Now, create a new directory named templates, and in that directory create two files. The first is named base.tmpl. The second is named view-status.tmpl. Then, in templates/base.tmpl, add the following code.

{{define "base"}}
<!doctype html>
<html lang='en'>
<head>
    <meta charset='utf-8'>
    <title>{{ template "title" . }}</title>
    <style>
        :root {
            --color: #45464c;
        }
        a {
            text-decoration-thickness: 2px;
            text-underline-offset: 2px;
        }
        body {
            padding: 1em;
            font-family: 'Open Sans', sans-serif;
            line-height: 1.5;
        }
        h1 {
            margin-top: 0.1em;
        }
        footer {
            margin-top: 1.5em;
            margin-left: 0.1em;
            color: var(--color);
        }
        th {
            text-align: start;
        }
        thead {
            border-block-end: 2px solid;
            background: whitesmoke;
        }
        table {
            display: table;
            border-spacing: 2px;
            border-collapse: collapse;
            box-sizing: border-box;
            text-indent: 0;
            text-align: left;
            caption-side: top;
        }
        tr {
            border-bottom: 1px solid;
        }
        th,
        td {
            border: 1px solid lightgrey;
            padding: 0.50rem;
            padding-left: 1rem;
        }
        tr th {
            text-align: left;
        }
    </style>
</head>
<body>
    {{ template "main" . }}
</body>
<footer>
    Powered by <a href="https://twilio.com/try-twilio" target="_blank">Twilio</a>.
</footer>
</html>
{{end}}

As the name implies, this is the base template, containing the core page elements, e.g., the head, body, and footer tags. It also defines two blocks:

  • title, which stores the page's title
  • main, which stores the route-specific code

While not strictly necessary, this approach has the benefit of letting you store route-specific code in a route-specific template, which we'll do now.

In templates/view-status.tmpl, add the following code:

{{define "title"}}SMS' Status{{end}}

{{define "main"}}
<header>
    <h1>SMS' Status Records</a></h1>
</header>
<main>
    <table width="100%">
        <thead>
            <tr>
                <th>Message SID</th>
                <th>Status</th>
                <th>To</th>
                <th>From</th>
            </tr>
        </thead>
        <tbody>
            {{range .StatusList}}
            <tr>
                <td>{{.MessageSID}}</td>
                <td>{{.Status}}</td>
                <td>{{.To}}</td>
                <td>{{.From}}</td>
            </tr>
            {{end}}
        </tbody>
    </table>
</main>
{{end}}

This template sets the page's title to "SMS' Status", and body (main) to a table containing a row for each status record retrieved from the CSV file.

Implement logging for SMS status updates

To effectively track and debug SMS delivery, it's important to log all status changes. So, this section focuses on setting up a robust logging system in Go, specifically one that logs details of sent Twilio SMS messages to both a file and the console.

To do that, add the code below to the end of main.go.

func init() {
	logFile, err := os.OpenFile("sms_status.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
	if err != nil {
		log.Fatalf("Failed to open log file: %v", err)
	}
	multiWriter := io.MultiWriter(os.Stdout, logFile)
	log.SetOutput(multiWriter)
	log.SetFlags(log.LstdFlags | log.Lshortfile)
}

Expose your local application to the public internet

Since Twilio needs to reach your "/sms" endpoint. So, we'll use ngrok to expose the application to the web. Open a terminal and run the following command:

ngrok http 8080

Ngrok will generate a forwarding URL (e.g., https://1234-abc.ngrok.io), which you can see in the screenshot below, that forwards requests to your local server. You will use this URL to configure the webhook in your Twilio dashboard so that Twilio can send SMS status updates directly to your "/sms" endpoint.

Ngrok interface

Now, copy the generated Forwarding URL. Then, paste it in place of <ngrok_forwarding_url> in .env. Then, In your Twilio Console dashboard, navigate to Phone Numbers > Manage > Active Numbers.

Screenshot of Twilio console showing phone number messaging configuration options.

There, click on your Twilio number, go to the Configure tab, and under the Messaging Configuration section, configure the Twilio webhook by setting:

  • A message comes in to "Webhook"
  • A message comes in's URL field to the ngrok Forwarding URL with "/sms" append at the end
  • A message comes in's HTTP field to GET

After that, click on the Save configuration button to save the settings, as shown in the screenshot above.

Then, replace <ngrok_forwarding_url> in .env with the ngrok Forwarding URL as well and save the file.

Test the application

To test the application, you need to start the application development server. To do that, open another terminal tab or window and run the command below:

go run main.go

Proper testing ensures your SMS tracking system works as expected before going live. To do that, use curl to send an SMS, by running the command below in a new terminal tab or window, after replacing <Your phone number> with your E.164-formatted mobile/cell phone number.

curl \
    --data-urlencode "To=<Your phone number>" \
    --data-urlencode "Body=Here is the body of the message." \
    http://localhost:8080/send-sms

Alternatively, if you prefer Postman, create a new POST request to http://localhost:8080/send-sms. Click the Body tab, then click x-www-form-urlencoded, as in the screenshot above. Then, add two keys: "To" and "Body", and set them to your E.164-formatted mobile/cell number and the message body of your choice, respectively. After you're prepared the request, click Send to send it.

Regardless of your choice of network testing tool, you'll shortly receive an SMS on your phone with the message body that you provided.

Use your network tool of choice, if you prefer.

Finally, open http://localhost:8080/view-sms-status in your browser of choice, where you'll see it print out a list of status records, similar to the screenshot below.

A table displaying SMS status records with message SID, status, and recipient numbers

That's how to build a real-time SMS status tracking and logging using Go and Twilio

With just a single route, a Twilio webhook, and some HTML, you now have a functional system that captures incoming messages and status updates, logs them cleanly, and displays them in real-time through a web interface.

This setup is not only lightweight and efficient but also flexible enough to expand with features like database storage, notifications, or analytics. Whether you're building for monitoring, debugging, or insight into SMS flows, this project gives you a solid foundation to build upon.

David Fagbuyiro is a software engineer and technical writer who is passionate about extending his knowledge to developers through technical writing. You can find him on LinkedIn .

The log icon was created by juicy_fish and the tracking icon was created by iconixar on Flaticon.