Go SDK

Sending messages from an Echo service with the Sent Go SDK

This guide shows you how to wire Sent messaging into an existing Echo service: install the Go SDK, configure a shared client, send a template message from a handler, receive delivery webhooks, and verify the whole loop in sandbox mode.

Prerequisites

This guide assumes a working Echo v4 service on Go 1.22+ and familiarity with handlers and groups. You also need:

Install the SDK

Add the SDK to your existing module:

go get github.com/sentdm/sent-dm-go

Configure the client

Set your credentials as environment variables so they stay out of code; the webhook secret arrives in step 4:

export SENT_DM_API_KEY="your-api-key"
export SENT_DM_WEBHOOK_SECRET="whsec_your_signing_secret"

Create one shared client at startup and pass it to your handlers:

// main.go (excerpt)
client := sentdm.NewClient(
    option.WithAPIKey(os.Getenv("SENT_DM_API_KEY")),
)

The imports are github.com/sentdm/sent-dm-go and github.com/sentdm/sent-dm-go/option. The client is safe for concurrent use; one instance serves the whole process.

Send a template message from a handler

Add a handler that binds the request and calls Messages.Send; the pass-through sandbox flag lets callers exercise the endpoint without delivering anything:

// internal/handler/message.go
package handler

import (
    "context"
    "net/http"
    "time"

    "github.com/labstack/echo/v4"
    "github.com/sentdm/sent-dm-go"
)

type SendMessageRequest struct {
    To           []string          `json:"to"`             // E.164 numbers
    TemplateName string            `json:"template_name"`  // or template_id, never both
    Parameters   map[string]string `json:"parameters,omitempty"`
    Channels     []string          `json:"channels,omitempty"`
    Sandbox      bool              `json:"sandbox"`        // true = validate and simulate only
}

type MessageHandler struct {
    client *sentdm.Client
}

func NewMessageHandler(client *sentdm.Client) *MessageHandler {
    return &MessageHandler{client: client}
}

func (h *MessageHandler) Register(e *echo.Echo) {
    e.POST("/api/messages/send", h.SendMessage)
}

func (h *MessageHandler) SendMessage(c echo.Context) error {
    var req SendMessageRequest
    if err := c.Bind(&req); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }
    if len(req.To) == 0 || req.TemplateName == "" {
        return echo.NewHTTPError(http.StatusBadRequest, "to and template_name are required")
    }

    ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
    defer cancel()

    response, err := h.client.Messages.Send(ctx, sentdm.MessageSendParams{
        To: req.To,
        Template: sentdm.MessageSendParamsTemplate{
            Name:       sentdm.String(req.TemplateName),
            Parameters: req.Parameters,
        },
        Channel: req.Channels, // omit to let Sent pick per recipient
        Sandbox: sentdm.Bool(req.Sandbox),
    })
    if err != nil {
        return echo.NewHTTPError(http.StatusBadGateway, err.Error())
    }

    recipient := response.Data.Recipients[0]
    return c.JSON(http.StatusAccepted, map[string]string{
        "message_id": recipient.MessageID,
        "status":     response.Data.Status,
    })
}

Sent accepts sends asynchronously: the API responds with status QUEUED and one message_id per recipient-and-channel pair. Store the message_id: delivery outcomes arrive on your webhook endpoint instead of in this response.

Receive delivery webhooks

Add a webhook handler that verifies the X-Webhook-Signature header against the raw request body before trusting any event. The scheme is HMAC-SHA256 over {X-Webhook-ID}.{X-Webhook-Timestamp}.{rawBody}, keyed with the base64-decoded secret after stripping its whsec_ prefix. Refer to webhook signature verification for the full scheme. Every event arrives in the same envelope (field, event, timestamp, payload), so one handler routes all of them:

// internal/handler/webhook.go
package handler

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "io"
    "log/slog"
    "net/http"
    "os"
    "strings"
    "time"

    "github.com/labstack/echo/v4"
)

// WebhookEvent is the envelope Sent posts to your endpoint. Event is set for
// message events (for example, "message.delivered") and omitted for template events.
type WebhookEvent struct {
    Field     string         `json:"field"`
    Event     string         `json:"event,omitempty"`
    Timestamp time.Time      `json:"timestamp"`
    Payload   WebhookPayload `json:"payload"`
}

type WebhookPayload struct {
    MessageID     string `json:"message_id,omitempty"`
    MessageStatus string `json:"message_status,omitempty"`
    Channel       string `json:"channel,omitempty"`
    InboundNumber string `json:"inbound_number,omitempty"`
    Text          string `json:"text,omitempty"`
}

type WebhookHandler struct {
    secret string
    logger *slog.Logger
}

func NewWebhookHandler(logger *slog.Logger) *WebhookHandler {
    return &WebhookHandler{secret: os.Getenv("SENT_DM_WEBHOOK_SECRET"), logger: logger}
}

func (h *WebhookHandler) Register(e *echo.Echo) {
    e.POST("/webhooks/sent", h.HandleWebhook)
}

// verifySignature: strip the "whsec_" prefix, base64-decode the remainder to get
// the raw key, sign "{webhookID}.{timestamp}.{rawBody}" with HMAC-SHA256, and
// compare against the "v1,{base64(hmac)}" header using a constant-time check.
func (h *WebhookHandler) verifySignature(webhookID, timestamp string, body []byte, signature string) bool {
    if h.secret == "" {
        return false // fail closed when the secret is not configured
    }
    keyBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(h.secret, "whsec_"))
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, keyBytes)
    mac.Write([]byte(webhookID + "." + timestamp + "." + string(body)))
    expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(signature), []byte(expected))
}

func (h *WebhookHandler) HandleWebhook(c echo.Context) error {
    body, err := io.ReadAll(c.Request().Body)
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "failed to read body")
    }

    webhookID := c.Request().Header.Get("X-Webhook-ID")
    timestamp := c.Request().Header.Get("X-Webhook-Timestamp")
    signature := c.Request().Header.Get("X-Webhook-Signature")
    if !h.verifySignature(webhookID, timestamp, body, signature) {
        return echo.NewHTTPError(http.StatusUnauthorized, "invalid signature")
    }

    var event WebhookEvent
    if err := json.Unmarshal(body, &event); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, "invalid JSON")
    }

    if event.Field == "message" {
        switch event.Event {
        case "message.delivered":
            h.logger.Info("message delivered", slog.String("message_id", event.Payload.MessageID))
        case "message.failed":
            h.logger.Error("message failed",
                slog.String("message_id", event.Payload.MessageID),
                slog.String("status", event.Payload.MessageStatus))
        case "message.received":
            h.logger.Info("inbound message",
                slog.String("from", event.Payload.InboundNumber),
                slog.String("text", event.Payload.Text))
        default:
            h.logger.Info("message status updated",
                slog.String("message_id", event.Payload.MessageID),
                slog.String("status", event.Payload.MessageStatus))
        }
    }

    return c.JSON(http.StatusOK, map[string]bool{"received": true})
}

Register the handler outside any authentication middleware, because Sent authenticates with the signature. Then tell Sent where to deliver events. If you prefer a UI, use the webhooks getting started guide; otherwise register over the API:

curl -X POST https://api.sent.dm/v3/webhooks \
  -H "x-api-key: $SENT_DM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Echo integration",
    "endpoint_url": "https://your-domain.example/webhooks/sent",
    "event_types": ["message"]
  }'

Copy two values from the response: the webhook id (used to test delivery in the next step) and signing_secret (put it in SENT_DM_WEBHOOK_SECRET). The message event type covers every message event; the webhook event types reference lists all payload fields.

Verify the integration

Start the service with your credentials loaded:

go run ./cmd/api

Send a sandbox message through your new handler. Full validation runs, but nothing is delivered and no credits are consumed:

curl -X POST http://localhost:8080/api/messages/send \
  -H "Content-Type: application/json" \
  -d '{"to": ["+14155551234"], "template_name": "welcome",
       "parameters": {"name": "Ada"}, "sandbox": true}'

The response should contain a message_id and "status": "QUEUED". A 400 here means the request shape is wrong: sandbox requests return real validation errors.

Now confirm webhook delivery end to end. Ask Sent to deliver a signed test event, replacing the ID with the webhook id you copied:

curl -X POST https://api.sent.dm/v3/webhooks/YOUR_WEBHOOK_ID/test \
  -H "x-api-key: $SENT_DM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "message.delivered"}'

Your service log should show a message delivered line, and the endpoint should have answered 200 {"received": true}. Test events travel the same signed delivery pipeline as real events, so a 401 in your log means the signing secret or verification code is wrong. Sent attempts a test event exactly once, so re-run the command after each fix.

Adapt this to your app

  • If you use Echo's Validator interface with go-playground/validator, move the required-field checks into struct tags and call c.Validate(&req) after binding.
  • If webhook processing does slow work (database writes, downstream calls), acknowledge with 200 first and process in a goroutine or job queue so retries do not pile up; see handling webhook retries.
  • To send to many recipients, pass them all in To. Sent creates one message per recipient-and-channel pair in a single call.
  • To send free-form text instead of a template, set Text instead of Template. Each send carries exactly one of the two.

Appendix: production scaffolding

The numbered steps stay on the core messaging tasks. The blocks below are optional scaffolding for a production Echo service. Adapt them to your own conventions rather than adopting them wholesale.

Next steps

On this page