Go SDK
Echo Integration
Production-ready integration with the Echo web framework featuring structured logging, request validation, graceful shutdown, and comprehensive error handling.
This example uses github.com/sentdm/sent-dm-go with Echo v4 patterns including middleware, service layer, and clean architecture principles.
Project Structure
sent-echo-app/
├── cmd/api/main.go # Application entry
├── internal/
│ ├── config/config.go # Configuration
│ ├── handler/
│ │ ├── health.go # Health endpoints
│ │ ├── message.go # Message handlers
│ │ └── webhook.go # Webhook handlers
│ ├── middleware/
│ │ ├── logger.go # Request logging
│ │ └── error.go # Error handling
│ ├── model/request.go # Request DTOs
│ ├── model/response.go # Response models
│ ├── service/
│ │ ├── message.go # Message logic
│ │ └── webhook.go # Webhook processing
│ └── validator/validator.go # Validation
├── pkg/sentclient/client.go # Sent client wrapper
├── docker-compose.yml
├── Dockerfile
├── go.mod
└── .envConfiguration
// internal/config/config.go
package config
import (
"fmt"
"time"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
Server ServerConfig
Sent SentConfig
Log LogConfig
RateLimit RateLimitConfig
}
type ServerConfig struct {
Port string `envconfig:"PORT" default:"8080"`
ReadTimeout time.Duration `envconfig:"SERVER_READ_TIMEOUT" default:"5s"`
WriteTimeout time.Duration `envconfig:"SERVER_WRITE_TIMEOUT" default:"10s"`
IdleTimeout time.Duration `envconfig:"SERVER_IDLE_TIMEOUT" default:"120s"`
Environment string `envconfig:"ENVIRONMENT" default:"development"`
}
type SentConfig struct {
APIKey string `envconfig:"SENT_DM_API_KEY" required:"true"`
WebhookSecret string `envconfig:"SENT_DM_WEBHOOK_SECRET" required:"true"`
}
type LogConfig struct {
Level string `envconfig:"LOG_LEVEL" default:"info"`
Format string `envconfig:"LOG_FORMAT" default:"json"`
}
type RateLimitConfig struct {
RequestsPerSecond float64 `envconfig:"RATE_LIMIT_RPS" default:"10"`
BurstSize int `envconfig:"RATE_LIMIT_BURST" default:"20"`
}
func Load() (*Config, error) {
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return &cfg, nil
}
func NewLogger(cfg LogConfig) (*zap.Logger, error) {
level, _ := zapcore.ParseLevel(cfg.Level)
encoderConfig := zapcore.EncoderConfig{
TimeKey: "timestamp", LevelKey: "level", MessageKey: "msg",
EncodeLevel: zapcore.LowercaseLevelEncoder, EncodeTime: zapcore.ISO8601TimeEncoder,
}
var encoder zapcore.Encoder
if cfg.Format == "console" {
encoder = zapcore.NewConsoleEncoder(encoderConfig)
} else {
encoder = zapcore.NewJSONEncoder(encoderConfig)
}
core := zapcore.NewCore(encoder, zapcore.AddSync(os.Stdout), level)
return zap.New(core, zap.AddCaller()), nil
}Models
// internal/model/response.go
package model
import "net/http"
type ErrorResponse struct {
Success bool `json:"success"`
Error ErrorInfo `json:"error,omitempty"`
RequestID string `json:"requestId,omitempty"`
}
type ErrorInfo struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
type SuccessResponse[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
}
func NewSuccessResponse[T any](data T) SuccessResponse[T] {
return SuccessResponse[T]{Success: true, Data: data}
}
func NewErrorResponse(code, message string) ErrorResponse {
return ErrorResponse{Success: false, Error: ErrorInfo{Code: code, Message: message}}
}
type HTTPError struct {
Code int
ErrorCode string
Message string
InnerError error
}
func (e *HTTPError) Error() string { return e.Message }
func (e *HTTPError) Unwrap() error { return e.InnerError }
var (
ErrBadRequest = &HTTPError{Code: http.StatusBadRequest, ErrorCode: "BAD_REQUEST", Message: "Invalid request"}
ErrUnauthorized = &HTTPError{Code: http.StatusUnauthorized, ErrorCode: "UNAUTHORIZED", Message: "Authentication required"}
ErrValidation = &HTTPError{Code: http.StatusUnprocessableEntity, ErrorCode: "VALIDATION_ERROR", Message: "Validation failed"}
)
func BindValidationError(err error) *HTTPError {
return &HTTPError{Code: http.StatusBadRequest, ErrorCode: "VALIDATION_ERROR", Message: "Request validation failed", Details: err.Error(), InnerError: err}
}
// internal/model/request.go
type SendMessageRequest struct {
To []string `json:"to" validate:"required,min=1,dive,e164"`
Template TemplateRequest `json:"template" validate:"required"`
Channels []string `json:"channels,omitempty" validate:"omitempty,dive,oneof=sms whatsapp email telegram"`
}
type TemplateRequest struct {
ID string `json:"id,omitempty" validate:"omitempty,uuid"`
Name string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
Parameters map[string]string `json:"parameters,omitempty"`
}
type WelcomeMessageRequest struct {
PhoneNumber string `json:"phoneNumber" validate:"required,e164"`
Name string `json:"name,omitempty" validate:"omitempty,max=100"`
}Sent Client
// pkg/sentclient/client.go
package sentclient
import (
"context"
"fmt"
"time"
"github.com/sentdm/sent-dm-go"
"github.com/sentdm/sent-dm-go/option"
"go.uber.org/zap"
)
type Client struct {
client *sentdm.Client
logger *zap.Logger
}
func New(apiKey string, logger *zap.Logger) *Client {
return &Client{
client: sentdm.NewClient(option.WithAPIKey(apiKey)),
logger: logger.Named("sent-client"),
}
}
type SendMessageResult struct {
MessageID string
Status string
Channel string
}
func (c *Client) SendMessage(ctx context.Context, to []string, template sentdm.MessageSendParamsTemplate, channels []string) (*SendMessageResult, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
params := sentdm.MessageSendParams{To: to, Template: template}
if len(channels) > 0 { params.Channels = sentdm.StringSlice(channels) }
c.logger.Debug("sending message", zap.Strings("to", to), zap.Strings("channels", channels))
response, err := c.client.Messages.Send(ctx, params)
if err != nil {
c.logger.Error("failed to send message", zap.Error(err))
return nil, fmt.Errorf("failed to send message: %w", err)
}
if len(response.Data.Messages) == 0 { return nil, fmt.Errorf("no messages in response") }
msg := response.Data.Messages[0]
return &SendMessageResult{MessageID: msg.ID, Status: msg.Status, Channel: msg.Channel}, nil
}
func (c *Client) ParseEvent(body string) (*sentdm.WebhookEvent, error) {
return c.client.Webhooks.ParseEvent(body)
}
func (c *Client) VerifySignature(body, signature, secret string) bool {
return c.client.Webhooks.VerifySignature(body, signature, secret)
}Middleware
// internal/middleware/logger.go
package middleware
import (
"time"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func Logger(logger *zap.Logger) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
err := next(c)
logger.Info("request",
zap.String("method", c.Request().Method),
zap.String("path", c.Request().URL.Path),
zap.Int("status", c.Response().Status),
zap.Duration("latency", time.Since(start)),
)
return err
}
}
}
// internal/middleware/error.go
package middleware
import (
"errors"
"net/http"
"github.com/labstack/echo/v4"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/model"
"go.uber.org/zap"
)
func ErrorHandler(logger *zap.Logger) echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
if c.Response().Committed { return }
requestID := c.Response().Header().Get(echo.HeaderXRequestID)
var httpErr *model.HTTPError
if errors.As(err, &httpErr) {
c.JSON(httpErr.Code, model.ErrorResponse{Success: false, RequestID: requestID, Error: model.ErrorInfo{Code: httpErr.ErrorCode, Message: httpErr.Message, Details: httpErr.Details}})
return
}
var echoErr *echo.HTTPError
if errors.As(err, &echoErr) {
c.JSON(echoErr.Code, model.NewErrorResponse("ERROR", echoErr.Error()))
return
}
logger.Error("unexpected error", zap.Error(err), zap.String("requestId", requestID))
c.JSON(http.StatusInternalServerError, model.NewErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"))
}
}Service Layer
// internal/service/message.go
package service
import (
"context"
"fmt"
"github.com/sentdm/sent-dm-go"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/model"
"github.com/sentdm/sent-dm-go/sent-echo-app/pkg/sentclient"
"go.uber.org/zap"
)
type MessageService struct {
sentClient *sentclient.Client
logger *zap.Logger
}
func NewMessageService(sentClient *sentclient.Client, logger *zap.Logger) *MessageService {
return &MessageService{sentClient: sentClient, logger: logger.Named("message-service")}
}
type SendMessageResult struct {
MessageID string `json:"messageId"`
Status string `json:"status"`
Channel string `json:"channel,omitempty"`
}
func (s *MessageService) SendMessage(ctx context.Context, req *model.SendMessageRequest) (*SendMessageResult, error) {
template := sentdm.MessageSendParamsTemplate{ID: sentdm.String(req.Template.ID), Name: sentdm.String(req.Template.Name), Parameters: req.Template.Parameters}
result, err := s.sentClient.SendMessage(ctx, req.To, template, req.Channels)
if err != nil {
return nil, fmt.Errorf("failed to send message: %w", err)
}
return &SendMessageResult{MessageID: result.MessageID, Status: result.Status, Channel: result.Channel}, nil
}
func (s *MessageService) SendWelcomeMessage(ctx context.Context, phoneNumber, name string) (*SendMessageResult, error) {
if name == "" { name = "Valued Customer" }
template := sentdm.MessageSendParamsTemplate{ID: sentdm.String("welcome-template-id"), Name: sentdm.String("welcome"), Parameters: map[string]string{"name": name}}
result, err := s.sentClient.SendMessage(ctx, []string{phoneNumber}, template, []string{"whatsapp"})
if err != nil { return nil, err }
s.logger.Info("welcome message sent", zap.String("phoneNumber", phoneNumber), zap.String("messageId", result.MessageID))
return &SendMessageResult{MessageID: result.MessageID, Status: result.Status}, nil
}
// internal/service/webhook.go
package service
import (
"context"
"fmt"
"github.com/sentdm/sent-dm-go"
"github.com/sentdm/sent-dm-go/sent-echo-app/pkg/sentclient"
"go.uber.org/zap"
)
type WebhookService struct {
sentClient *sentclient.Client
logger *zap.Logger
secret string
}
func NewWebhookService(sentClient *sentclient.Client, logger *zap.Logger, secret string) *WebhookService {
return &WebhookService{sentClient: sentClient, logger: logger.Named("webhook-service"), secret: secret}
}
func (s *WebhookService) VerifySignature(body, signature string) bool {
if signature == "" { s.logger.Warn("missing webhook signature"); return false }
return s.sentClient.VerifySignature(body, signature, s.secret)
}
func (s *WebhookService) ProcessEvent(ctx context.Context, body string) error {
event, err := s.sentClient.ParseEvent(body)
if err != nil { return fmt.Errorf("failed to parse event: %w", err) }
s.logger.Info("processing webhook event", zap.String("type", event.Type), zap.String("eventId", event.ID))
switch event.Type {
case "message.delivered":
s.logger.Info("message delivered", zap.String("messageId", event.Data.MessageID))
case "message.failed":
s.logger.Error("message failed", zap.String("messageId", event.Data.MessageID), zap.String("error", event.Data.Error.Message))
case "message.status.updated":
s.logger.Info("message status updated", zap.String("messageId", event.Data.MessageID), zap.String("status", event.Data.Status))
default:
s.logger.Warn("unhandled event type", zap.String("type", event.Type))
}
return nil
}Handlers
// internal/handler/health.go
package handler
import (
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/model"
)
type HealthHandler struct{ startTime time.Time }
func NewHealthHandler() *HealthHandler { return &HealthHandler{startTime: time.Now()} }
func (h *HealthHandler) Register(e *echo.Echo) {
e.GET("/health", func(c echo.Context) error {
return c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]interface{}{
"status": "healthy", "uptime": time.Since(h.startTime).String(), "timestamp": time.Now().Unix(),
}))
})
e.GET("/ready", func(c echo.Context) error {
return c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]interface{}{"status": "ready"}))
})
}
// internal/handler/message.go
package handler
import (
"net/http"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/model"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/service"
"go.uber.org/zap"
)
type MessageHandler struct {
messageService *service.MessageService
logger *zap.Logger
}
func NewMessageHandler(messageService *service.MessageService, logger *zap.Logger) *MessageHandler {
return &MessageHandler{messageService: messageService, logger: logger.Named("message-handler")}
}
func (h *MessageHandler) Register(e *echo.Echo) {
g := e.Group("/api/v1/messages")
g.POST("/send", h.SendMessage)
g.POST("/welcome", h.SendWelcome)
}
func (h *MessageHandler) SendMessage(c echo.Context) error {
var req model.SendMessageRequest
if err := c.Bind(&req); err != nil { return model.BindValidationError(err) }
if err := c.Validate(&req); err != nil { return model.BindValidationError(err) }
result, err := h.messageService.SendMessage(c.Request().Context(), &req)
if err != nil {
h.logger.Error("failed to send message", zap.Error(err))
return err
}
return c.JSON(http.StatusOK, model.NewSuccessResponse(result))
}
func (h *MessageHandler) SendWelcome(c echo.Context) error {
var req model.WelcomeMessageRequest
if err := c.Bind(&req); err != nil { return model.BindValidationError(err) }
if err := c.Validate(&req); err != nil { return model.BindValidationError(err) }
result, err := h.messageService.SendWelcomeMessage(c.Request().Context(), req.PhoneNumber, req.Name)
if err != nil {
h.logger.Error("failed to send welcome message", zap.Error(err))
return err
}
return c.JSON(http.StatusOK, model.NewSuccessResponse(result))
}
// internal/handler/webhook.go
package handler
import (
"io"
"net/http"
"github.com/labstack/echo/v4"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/model"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/service"
"go.uber.org/zap"
)
type WebhookHandler struct {
webhookService *service.WebhookService
logger *zap.Logger
}
func NewWebhookHandler(webhookService *service.WebhookService, logger *zap.Logger) *WebhookHandler {
return &WebhookHandler{webhookService: webhookService, logger: logger.Named("webhook-handler")}
}
func (h *WebhookHandler) Register(e *echo.Echo) {
e.POST("/webhooks/sent", h.HandleWebhook)
}
func (h *WebhookHandler) HandleWebhook(c echo.Context) error {
body, err := io.ReadAll(c.Request().Body)
if err != nil { return model.BindValidationError(err) }
signature := c.Request().Header.Get("X-Webhook-Signature")
if !h.webhookService.VerifySignature(string(body), signature) {
h.logger.Warn("invalid webhook signature", zap.String("ip", c.RealIP()))
return model.ErrUnauthorized
}
if err := h.webhookService.ProcessEvent(c.Request().Context(), string(body)); err != nil {
h.logger.Error("failed to process webhook", zap.Error(err))
return &model.HTTPError{Code: http.StatusBadRequest, ErrorCode: "PROCESSING_ERROR", Message: "Failed to process webhook", Details: err.Error()}
}
return c.JSON(http.StatusOK, model.NewSuccessResponse(map[string]bool{"received": true}))
}Main Application
// cmd/api/main.go
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/config"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/handler"
appmiddleware "github.com/sentdm/sent-dm-go/sent-echo-app/internal/middleware"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/service"
appvalidator "github.com/sentdm/sent-dm-go/sent-echo-app/internal/validator"
"github.com/sentdm/sent-dm-go/sent-echo-app/pkg/sentclient"
"go.uber.org/zap"
)
func main() {
cfg, err := config.Load()
if err != nil { panic(err) }
logger, err := config.NewLogger(cfg.Log)
if err != nil { panic(err) }
defer logger.Sync()
sentClient := sentclient.New(cfg.Sent.APIKey, logger)
messageService := service.NewMessageService(sentClient, logger)
webhookService := service.NewWebhookService(sentClient, logger, cfg.Sent.WebhookSecret)
e := echo.New()
e.HideBanner = true
e.Validator = appvalidator.New()
e.HTTPErrorHandler = appmiddleware.ErrorHandler(logger)
e.Use(middleware.RequestID())
e.Use(appmiddleware.Logger(logger))
e.Use(middleware.Recover())
e.Use(middleware.CORS())
e.Use(middleware.Secure())
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(cfg.RateLimit.RequestsPerSecond)))
handler.NewHealthHandler().Register(e)
handler.NewMessageHandler(messageService, logger).Register(e)
handler.NewWebhookHandler(webhookService, logger).Register(e)
srv := &http.Server{
Addr: ":" + cfg.Server.Port, Handler: e,
ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout,
}
go func() {
logger.Info("starting server", zap.String("port", cfg.Server.Port))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal("server failed", zap.Error(err))
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Fatal("forced shutdown", zap.Error(err))
}
logger.Info("server exited")
}Testing
// internal/handler/message_test.go
package handler
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/model"
"github.com/sentdm/sent-dm-go/sent-echo-app/internal/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.uber.org/zap"
)
type MockMessageService struct{ mock.Mock }
func (m *MockMessageService) SendMessage(ctx context.Context, req *model.SendMessageRequest) (*service.SendMessageResult, error) {
args := m.Called(ctx, req)
if args.Get(0) == nil { return nil, args.Error(1) }
return args.Get(0).(*service.SendMessageResult), args.Error(1)
}
func (m *MockMessageService) SendWelcomeMessage(ctx context.Context, phoneNumber, name string) (*service.SendMessageResult, error) {
args := m.Called(ctx, phoneNumber, name)
if args.Get(0) == nil { return nil, args.Error(1) }
return args.Get(0).(*service.SendMessageResult), args.Error(1)
}
func TestMessageHandler_SendMessage(t *testing.T) {
tests := []struct {
name string
body interface{}
mockSetup func(*MockMessageService)
wantStatus int
wantSuccess bool
}{
{
name: "success",
body: model.SendMessageRequest{To: []string{"+1234567890"}, Template: model.TemplateRequest{ID: "tpl-123", Name: "welcome"}},
mockSetup: func(m *MockMessageService) {
m.On("SendMessage", mock.Anything, mock.Anything).Return(&service.SendMessageResult{MessageID: "msg_123", Status: "pending"}, nil)
},
wantStatus: 200, wantSuccess: true,
},
{
name: "invalid body",
body: "invalid",
mockSetup: func(m *MockMessageService) {},
wantStatus: 400, wantSuccess: false,
},
{
name: "service error",
body: model.SendMessageRequest{To: []string{"+1234567890"}, Template: model.TemplateRequest{ID: "tpl-123"}},
mockSetup: func(m *MockMessageService) {
m.On("SendMessage", mock.Anything, mock.Anything).Return(nil, errors.New("failed"))
},
wantStatus: 500, wantSuccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := echo.New()
mockSvc := new(MockMessageService)
tt.mockSetup(mockSvc)
h := NewMessageHandler(mockSvc, zap.NewNop())
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/messages/send", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := h.SendMessage(c)
if err != nil { e.HTTPErrorHandler(err, c) }
assert.Equal(t, tt.wantStatus, rec.Code)
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
assert.Equal(t, tt.wantSuccess, resp["success"])
mockSvc.AssertExpectations(t)
})
}
}Environment Variables
| Variable | Description | Required | Default |
|---|---|---|---|
SENT_DM_API_KEY | Sent DM API key | Yes | - |
SENT_DM_WEBHOOK_SECRET | Webhook signature secret | Yes | - |
PORT | Server port | No | 8080 |
SERVER_READ_TIMEOUT | Request read timeout | No | 5s |
SERVER_WRITE_TIMEOUT | Response write timeout | No | 10s |
ENVIRONMENT | Environment name | No | development |
LOG_LEVEL | Log level | No | info |
LOG_FORMAT | Log format (json/console) | No | json |
RATE_LIMIT_RPS | Rate limit per second | No | 10 |
Example .env
PORT=8080
SERVER_READ_TIMEOUT=5s
SERVER_WRITE_TIMEOUT=10s
ENVIRONMENT=development
SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here
LOG_LEVEL=info
LOG_FORMAT=json
RATE_LIMIT_RPS=10Docker
# Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -o main ./cmd/api
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]# docker-compose.yml
version: '3.8'
services:
api:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
- ENVIRONMENT=production
- SENT_DM_API_KEY=${SENT_DM_API_KEY}
- SENT_DM_WEBHOOK_SECRET=${SENT_DM_WEBHOOK_SECRET}
- LOG_LEVEL=info
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3go.mod
module github.com/sentdm/sent-dm-go/sent-echo-app
go 1.22
require (
github.com/go-playground/validator/v10 v10.18.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/labstack/echo/v4 v4.11.4
github.com/sentdm/sent-dm-go v0.7.0
github.com/stretchr/testify v1.9.0
go.uber.org/zap v1.27.0
)Next Steps
- Learn about best practices for production deployments
- Set up webhooks for delivery status updates
- Explore the Go SDK reference for advanced features