README
¶
loopsso-go
A small, dependency-free Go client for the Loops.so transactional email API.
It is a drop-in replacement for brevo-go:
the package name, builder, and method names match, so migrating means changing the import
path and the template ID literal.
Install
go get github.com/Trillion-Digital/loopsso-go
Requires Go 1.24.5 or newer.
Usage
package main
import (
"context"
"log"
"os"
"github.com/Trillion-Digital/loopsso-go/mail"
)
func main() {
// nil http client -> 30s timeout. Empty apiURL -> mail.DefaultAPIURL.
client := mail.NewLoopsMailClient(nil, os.Getenv("LOOPS_API_KEY"), "")
email := mail.NewEmail().
To("user@example.com", "").
TemplateID("clfq6dinn000yl70fgwwyp82l").
Param("name", "Alice").
Param("plan", "pro")
if err := client.SendEmail(context.Background(), email); err != nil {
log.Fatalf("send failed: %v", err)
}
}
TemplateID is the transactional email ID from your Loops dashboard, and Param /
Params populate the template's data variables.
Migrating from brevo-go
One call site change is required, plus three methods that become no-ops.
Required: TemplateID now takes a string. Loops identifies templates with cuids
(clfq6dinn000yl70fgwwyp82l), which an int cannot express, so TemplateID(101) becomes
TemplateID("clfq6dinn000yl70fgwwyp82l"). The compiler flags every occurrence.
Accepted but ignored. These compile unchanged so existing code keeps working, but the values never reach Loops:
| Call | Why it is ignored |
|---|---|
Sender(email, name) |
Loops takes the sender from the transactional template. There is no wire field. |
Subject(subject) |
Loops takes the subject from the template and offers no per-send override. |
the name argument of To(email, name) |
Loops accepts a bare recipient address. |
Behaviour changes:
Toreplaces the recipient instead of appending. Loops accepts one recipient per request.- A sender is no longer required by
Build(), since it is unused. MessageBuilder,NewMessage, andAddMessagesare gone. Loops has no batch endpoint; send one request per recipient and mind the rate limit below.- Errors are now typed. See below.
Adding the contact
AddToAudience sets Loops' addToAudience flag, which creates the contact if it does not
already exist:
email := mail.NewEmail().
To("user@example.com", "").
TemplateID("clfq6dinn000yl70fgwwyp82l").
AddToAudience()
Idempotency
Loops discards a duplicate send carrying an already-seen Idempotency-Key within 24 hours
and answers 409 instead:
key := mail.NewIdempotencyKey() // random UUIDv4
email := mail.NewEmail().
To("user@example.com", "").
TemplateID("clfq6dinn000yl70fgwwyp82l").
IdempotencyKey(key)
Keys are capped at 100 characters; Build rejects longer ones.
The key is never generated automatically. Deduplication only works when retries of one logical send reuse the same key, and only your code knows which attempts those are — generate the key when the send is first intended, persist it alongside the work, and reuse it on every retry. A fresh key per attempt gives you no protection at all.
Error handling
Failures come back as *mail.APIError, which carries the status code and Loops' message
and error.path fields. Four sentinels cover the common cases and match with errors.Is:
err := client.SendEmail(ctx, email)
switch {
case err == nil:
// sent
case errors.Is(err, mail.ErrRateLimited):
// 429 - back off and retry
case errors.Is(err, mail.ErrIdempotencyConflict):
// 409 - already sent within the last 24h, treat as success
case errors.Is(err, mail.ErrNotFound):
// 404 - no such transactional template
case errors.Is(err, mail.ErrUnauthorized):
// 401 - bad API key
default:
var apiErr *mail.APIError
if errors.As(err, &apiErr) {
log.Printf("loops %d: %s (path %q)", apiErr.StatusCode, apiErr.Message, apiErr.Path)
}
}
A 200 response carrying {"success": false} is also reported as an *APIError, so a nil
error always means the send was accepted.
Rate limits
Loops allows 10 requests per second per team. This client does not throttle or retry;
exceeding the limit surfaces as mail.ErrRateLimited and back-off is left to the caller.
Not implemented
- Attachments. Loops supports them, but only after you email Loops to enable the feature on your account.
- Automatic 429 retry.
- Other endpoints. Contacts, events, and campaigns are out of scope; this package
covers
POST /api/v1/transactionalonly.
Development
go vet ./...
go test ./... -race -cover
gofmt -l .
Tests are hermetic — they use httptest and make no network calls.
License
LGPL-2.1. See LICENSE.