landing page, Go and SMS notifications
SMS notifications here play the role of a reliable channel for consolidating the results of the conversation and reminding of agreements. They do not require the Internet or installation of applications and work everywhere, even with a weak connection signal.
Moreover, the example will be without cumbersome frameworks – only Go and pure HTML with a pinch of JavaScript.
Why is this needed in 2024
Unexpectedly, call back forms are still relevant. And it's not about technology, but about people. It’s easier for a client to leave a number on the website than to call themselves or look for contacts in instant messengers.
Moreover, SMS notifications remain the most reliable notification method. No internet needed. No application installation required. It works everywhere, even where the connection is barely breathing.
What will we get in the end?
Let's make two components:
minimalistic landing page with a call request form;
Go server for processing requests and sending SMS via API Exsolve.
It sounds simple, but the devil, as always, is in the details.
Let's start with the frontend
Let's create a simple but modern landing page. No heavy frameworks – just HTML5, CSS and pure JavaScript.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Заказать звонок</title>
<style>
.callback-form {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.callback-form.active {
display: block;
}
.overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
}
.overlay.active {
display: block;
}
</style>
</head>
<body>
<button onclick="showForm()">Заказать звонок</button>
<div class="overlay" id="overlay"></div>
<div class="callback-form" id="callbackForm">
<h2>Заказать обратный звонок</h2>
<form id="phoneForm">
<input type="text" id="name" placeholder="Ваше имя" required>
<input type="tel" id="phone" placeholder="+7 (___) ___-__-__" required>
<label>
<input type="checkbox" required>
Согласен с политикой конфиденциальности
</label>
<button type="submit">Отправить</button>
</form>
</div>
<script>
function showForm() {
document.getElementById('overlay').classList.add('active');
document.getElementById('callbackForm').classList.add('active');
}
document.getElementById('phoneForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
name: document.getElementById('name').value,
phone: document.getElementById('phone').value
};
try {
const response = await fetch('/api/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
if (response.ok) {
alert('Спасибо! Мы перезвоним вам в ближайшее время.');
} else {
alert('Произошла ошибка. Попробуйте позже.');
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка. Попробуйте позже.');
}
});
</script>
</body>
Standard HTML with JavaScript can do a lot. Position: fixed with transform is an ancient method for centering a modal window, working everywhere, from push-button Nokia phones to the latest iPhones. In this example I'm not using Bootstrap or Material UI because it can be done simply and reliably. Asynchronous sending via fetch eliminates the need to reload the page.
Writing a server in Go
Now let's move on to the server part. We will need:
processing incoming requests;
data validation;
integration with Exolve API;
spam protection.
Let's start with the basic structure of the project:
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"time"
)
type CallbackRequest struct {
Name string `json:"name"`
Phone string `json:"phone"`
}
type ExolveConfig struct {
ApiKey string
From string
To string
}
var (
config ExolveConfig
client *http.Client
)
func init() {
// Загружаем конфигурацию
config = ExolveConfig{
ApiKey: os.Getenv("EXOLVE_API_KEY"),
From: os.Getenv("SMS_FROM"),
To: os.Getenv("SMS_TO"),
}
// Инициализируем HTTP-клиент
client = &http.Client{
Timeout: time.Second * 10,
}
}
type ExolveResponse struct {
CallID string `json:"call_id"`
}
// Создаем структуру для отправки SMS через Exolve API
func sendSMS(phone string, name string) error {
smsBody := struct {
Number string `json:"number"` // Отправитель
Destination string `json:"destination"` // Получатель
Text string `json:"text"` // Текст сообщения
}{
Number: config.From,
Destination: phone,
Text: fmt.Sprintf("Здравствуйте, %s! Мы получили ваш запрос на обратный звонок и свяжемся с вами в ближайшее время.", name),
}
jsonData, err := json.Marshal(smsBody)
if err != nil {
return fmt.Errorf("ошибка при формировании SMS: %v", err)
}
req, err := http.NewRequest("POST", "https://api.exolve.ru/messaging/v1/SendSMS", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("ошибка при создании запроса: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("ошибка при отправке SMS: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
}
return nil
}
// Обработчик для API обратного звонка
func handleCallback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
return
}
var request CallbackRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Ошибка при разборе запроса", http.StatusBadRequest)
return
}
// Валидация номера телефона
if !validatePhone(request.Phone) {
http.Error(w, "Некорректный номер телефона", http.StatusBadRequest)
return
}
// Проверка на спам через Redis или другое хранилище
if isSpamRequest(request.Phone) {
http.Error(w, "Слишком много запросов", http.StatusTooManyRequests)
return
}
// Отправляем SMS
if err := sendSMS(request.Phone, request.Name); err != nil {
log.Printf("Ошибка при отправке SMS: %v", err)
http.Error(w, "Внутренняя ошибка сервера", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "Заявка принята",
})
}
func main() {
// Настраиваем CORS
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"}, // В продакшне заменить на конкретные домены
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
})
// Настройка маршрутов
mux := http.NewServeMux()
mux.HandleFunc("/api/callback", handleCallback)
// Оборачиваем наш мультиплексор в CORS middleware
handler := corsMiddleware.Handler(mux)
// Запуск сервера
log.Printf("Сервер запущен на порту :8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Fatal(err)
}
}
The core of the system is the built-in Go http package. Without unnecessary frameworks and dependencies. This makes it easier to scale and more convenient to debug. Plus less code means fewer bugs.
The CallbackRequest structure is intentionally simple: just a name and phone number. We will always have time to expand, but throwing out the excess later is a headache.
ExolveConfig keeps API settings in one place. Loading from environment variables – classic 12 factor app. Hardcoding credits into code in 2024 is bad manners, and DevOps won’t understand us.
Security and Validation
Let's add validation and anti-spam functions:
func validatePhone(phone string) bool {
// Очищаем номер от всего, кроме цифр
re := regexp.MustCompile(`\D`)
cleanPhone := re.ReplaceAllString(phone, "")
// Проверяем длину и начало номера
if len(cleanPhone) != 11 {
return false
}
if !strings.HasPrefix(cleanPhone, "7") {
return false
}
return true
}
// Простая проверка на спам через in-memory-хранилище
// В реальном проекте лучше использовать Redis
var (
requestLock sync.RWMutex
requestCounter = make(map[string]*rateLimiter)
)
type rateLimiter struct {
count int
firstCall time.Time
}
func isSpamRequest(phone string) bool {
requestLock.Lock()
defer requestLock.Unlock()
now := time.Now()
limiter, exists := requestCounter[phone]
if !exists {
requestCounter[phone] = &rateLimiter{
count: 1,
firstCall: now,
}
return false
}
// Сбрасываем счетчик каждый час
if now.Sub(limiter.firstCall) > time.Hour {
limiter.count = 1
limiter.firstCall = now
return false
}
limiter.count++
// Ограничиваем до 3 запросов в час
return limiter.count > 3
}
ValidatePhone checks the number based on length and first digit. No tricky regulars – they only complicate support. In addition, validating the number on the backend is the last line of defense; the front should do the main work.
Spam protection via in-memory storage is not ideal, but it will do for a start. Three requests per hour from one number is an adequate limit. We don’t use Redis right away – we start with something simple and complicate it as necessary.
Improving our service
But first, let's see how to process calls through Voice API from Exolve. This will allow us not only to send SMS, but also to automatically make calls.
type VoiceConfig struct {
ServiceID string // ID нашего голосового сервиса
Source string // Номер, с которого будем звонить
}
func makeCallback(phoneNumber string) error {
callbackBody := struct {
Source string `json:"source"`
Destination string `json:"destination"`
ServiceID string `json:"service_id"`
}{
Source: config.Voice.Source,
Destination: phoneNumber,
ServiceID: config.Voice.ServiceID,
}
jsonData, err := json.Marshal(callbackBody)
if err != nil {
return fmt.Errorf("ошибка при формировании запроса: %v", err)
}
req, err := http.NewRequest("POST", "https://api.exolve.ru/call/v1/MakeCallback", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("ошибка при создании запроса: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("ошибка при выполнении запроса: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
}
return nil
}
MakeCallback is our connection with Voice API from Exolve. The structure of the request is as transparent as possible: where we are calling from, where we are calling, what service we are using. No amateur performances – only what is really needed.
Logging and monitoring
An important part of any service is tracking its performance. Let's add structured logging:
type LogEntry struct {
Time time.Time `json:"time"`
Level string `json:"level"`
Phone string `json:"phone"`
Name string `json:"name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
}
func logRequest(r *http.Request, phone, name, status string, err error) {
entry := LogEntry{
Time: time.Now(),
Level: "info",
Phone: phone,
Name: name,
Status: status,
UserAgent: r.UserAgent(),
IP: r.RemoteAddr,
}
if err != nil {
entry.Level = "error"
entry.Error = err.Error()
}
jsonEntry, _ := json.Marshal(entry)
log.Println(string(jsonEntry))
}
The LogEntry structure is our Swiss Army knife for debugging. Time, level, phone number, name, status – everything that will help you understand what went wrong. UserAgent and IP are a bonus for particularly inquisitive DevOps.
Adding metrics
Prometheus has become the de facto standard for monitoring. Let's add basic metrics:
var (
requestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "callback_requests_total",
Help: "Общее количество запросов на обратный звонок",
},
[]string{"status"},
)
requestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "callback_request_duration_seconds",
Help: "Время обработки запроса",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
},
[]string{"status"},
)
)
// Оборачиваем наш обработчик для сбора метрик
func metricsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Создаем свой ResponseWriter для отслеживания статуса
srw := &statusResponseWriter{ResponseWriter: w}
next.ServeHTTP(srw, r)
duration := time.Since(start).Seconds()
status := fmt.Sprintf("%d", srw.status)
requestsTotal.WithLabelValues(status).Inc()
requestDuration.WithLabelValues(status).Observe(duration)
}
}
type statusResponseWriter struct {
http.ResponseWriter
status int
}
func (w *statusResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
RequestsTotal and requestDuration are two main elements of our monitoring. The first one counts requests, the second one measures time. It’s better not to enter unnecessary metrics: these two are already enough to monitor the health of the service.
Caching and optimization
Let's add Redis for more reliable spam control and caching:
type Cache struct {
client *redis.Client
}
func NewCache(addr string) (*Cache, error) {
client := redis.NewClient(&redis.Options{
Addr: addr,
})
// Проверяем подключение
if err := client.Ping().Err(); err != nil {
return nil, fmt.Errorf("ошибка подключения к Redis: %v", err)
}
return &Cache{client: client}, nil
}
func (c *Cache) CheckSpam(phone string) (bool, error) {
key := fmt.Sprintf("spam:%s", phone)
// Получаем количество запросов
count, err := c.client.Get(key).Int()
if err == redis.Nil {
// Ключа нет, создаем новый
err = c.client.Set(key, 1, time.Hour).Err()
return false, err
}
if err != nil {
return false, err
}
// Увеличиваем счетчик
count++
err = c.client.Set(key, count, time.Hour).Err()
if err != nil {
return false, err
}
return count > 3, nil
}
The cache structure wraps the Redis client. Spam checking is now more reliable: counters last for an hour and are not afraid of server restarts. And for heavy loads, Redis is just right: fast, reliable, time-tested.
Deployment and configuration
For containerization we use Docker:
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
COPY config.yaml .
EXPOSE 8080
CMD ["./main"]
We deploy the Dockerfile in two stages: first we build it, then we package it. Minimum layers – minimum problems. We use light and fast Alpine Linux as a base image.
And docker-compose for local development:
version: '3'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- REDIS_URL=redis:6379
- EXOLVE_API_KEY=${EXOLVE_API_KEY}
depends_on:
- redis
redis:
image: redis:alpine
ports:
- "6379:6379"
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=secret
depends_on:
- prometheus
Docker-compose is well suited for our task. It combines Redis, Prometheus, Grafana and avoids problems with setting up the environment.
What we got in the end
We have created a simple but reliable call back service with anti-spam protection, monitoring and logging. Thanks to integration with Exsolve API Our solution can not only send SMS, but also automatically make calls. However, it is still worth remembering that this is just an educational prototype and a demonstration of how Exolve can be used in your work. Based on it, you will have to decide how you will make a solution that is suitable specifically for your tasks and needs.
If you have any questions about integration with Exolve API or scaling the service, write in the comments – I will definitely answer.