Before you start
Accounts are set up with you during onboarding. For the DKIM record and the rest of your sending setup, see transactional email deliverability.
Send email in golang, step by step
The example sends a new sign-in security alert and tags it with the tenant and event type.
1. Store your API key
Keep the key out of your code. Set it as an environment variable on the server that calls tinyRelay.
export TINYRELAY_API_KEY="your-api-key"$env:TINYRELAY_API_KEY = "your-api-key"2. Build the request body
Typed structs with json tags keep field names right and let omitempty drop empty optional fields. A map works too for quick scripts.
package main
type Address struct {
Name string `json:"name,omitempty"`
Email string `json:"email"`
}
type Recipient struct {
Name string `json:"name,omitempty"`
Email string `json:"email"`
Attributes map[string]string `json:"attributes,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type Sender struct {
From Address `json:"from"`
ReplyTo *Address `json:"replyTo,omitempty"`
}
type Recipients struct {
To []Recipient `json:"to"`
}
type Email struct {
Subject string `json:"subject"`
Body string `json:"body"`
Sender Sender `json:"sender"`
Recipients Recipients `json:"recipients"`
CampaignID string `json:"campaignId,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
var alert = Email{
Subject: "New sign-in to your Acme account",
Body: "<p>Hi [FIRST_NAME], we noticed a new sign-in to your account.</p>",
Sender: Sender{From: Address{Name: "Acme Security", Email: "security@acme.com"}},
Recipients: Recipients{To: []Recipient{{
Name: "Ann Lee",
Email: "ann@example.com",
Attributes: map[string]string{"FIRST_NAME": "Ann"},
}}},
Metadata: map[string]string{"tenant_id": "acme", "event": "new_sign_in"},
}alert := map[string]any{
"subject": "New sign-in to your Acme account",
"body": "<p>Hi [FIRST_NAME], we noticed a new sign-in to your account.</p>",
"sender": map[string]any{
"from": map[string]string{"name": "Acme Security", "email": "security@acme.com"},
},
"recipients": map[string]any{
"to": []map[string]any{{
"email": "ann@example.com",
"attributes": map[string]string{"FIRST_NAME": "Ann"},
}},
},
"metadata": map[string]string{"tenant_id": "acme", "event": "new_sign_in"},
}3. POST it to /email/campaign
send marshals the body, sets the two headers and returns the requestId on 202 Accepted.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const apiURL = "https://api.tinyemail.com/v1/relay/email/campaign"
// Reuse one client; the zero-value client has no timeout.
var client = &http.Client{Timeout: 10 * time.Second}
type SendResponse struct {
RequestID string `json:"requestId"`
Message string `json:"message"`
}
func send(email any) (string, error) {
payload, err := json.Marshal(email)
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-RELAY-ACCESS-TOKEN", os.Getenv("TINYRELAY_API_KEY"))
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusAccepted {
return "", fmt.Errorf("tinyRelay %d: %s", resp.StatusCode, body)
}
var out SendResponse
if err := json.Unmarshal(body, &out); err != nil {
return "", err
}
return out.RequestID, nil
}
func main() {
id, err := send(alert)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("accepted:", id)
}Base URL from current site samples; confirm in the docs.
Two Go details worth knowing
Reuse one http.Client and set a timeout.The net/http docs say clients should be reused and are safe for concurrent use; a zero Timeout means no timeout at all.
\u003c in your JSON is fine.json.Marshal escapes <, > and & inside strings, so an HTML body shows up as \u003cp\u003e. It is still valid JSON and decodes to the same HTML.
4. Check for a 202
send returns the requestId on http.StatusAccepted (202) and an error with the status and body otherwise.
The 202 response
A request that passes validation returns 202. That means tinyRelay accepted the send; what happens to each email afterwards arrives as webhook events.
{
"requestId": "833c5151-5a98-4bad-a30f-7cc816352b7a",
"message": "SUCCESS"
}requestIdidentifies this send. Store it next to your own record.- If you did not set
campaignId, therequestIdis used as the campaign ID, and every webhook event for the send carries it. - A
202is not delivery. Watch forDELIVEREDorBOUNCEDevents.
Send to many users in one Go request
Append a Recipient per user. Each gets their own merge attributes and their own user_id in metadata, merged over the request-level tenant_id.
to := make([]Recipient, 0, len(users))
for _, u := range users {
to = append(to, Recipient{
Email: u.Email,
Attributes: map[string]string{"FIRST_NAME": u.FirstName},
Metadata: map[string]string{"user_id": u.ID},
})
}
id, err := send(Email{
Subject: "New sign-in to your Acme account",
Body: "<p>Hi [FIRST_NAME], we noticed a new sign-in to your account.</p>",
Sender: Sender{From: Address{Name: "Acme Security", Email: "security@acme.com"}},
Recipients: Recipients{To: to},
Metadata: map[string]string{"tenant_id": "acme", "event": "new_sign_in"},
})The rules, from the Send Email docs:
Request-level and recipient-level maps merge. The cap is on the merged map.
At most 500 recipients in one request may carry their own metadata.
Each metadata key can be up to 64 characters.
Values up to 256 characters: flat strings, numbers or booleans.
attributes are merge tags rendered into the body, such as [FIRST_NAME], and never echoed on webhooks. metadata is never rendered into the email and is always echoed on webhooks.
"attributes": { "FIRST_NAME": "Ann" } // in the email: "Hi Ann"
"metadata": { "user_id": "u_1042" } // on every webhook eventRequest-level and recipient-level maps merge, and the recipient's keys win.
// request { "tenant_id": "acme", "plan": "pro" }
// recipient { "plan": "team" }
// merged { "tenant_id": "acme", "plan": "team" }Values must be flat strings, numbers or booleans.
{ "order_id": "SO-1", "total": 42, "gift": true } // flat: fine
{ "order": { "id": "SO-1" } } // nested object: not flatMetadata is scoped to campaignId. Reusing a campaignId across batches replaces the earlier metadata, so use a unique one per batch or leave it out.
"campaignId": "password_reset_batch_0142" // unique per batch
// or leave it out: the requestId is used as the campaign IDHandle delivery webhooks
tinyRelay posts an event to your webhook endpoint for each thing that happens to an email. There are seven event types:
- SENDSent to the receiving server
- DELIVEREDEmail delivered
- BOUNCEDHARD_BOUNCED or SOFT_BOUNCED · adds bounceCategory
- OPENOpened by the recipient
- CLICKA tracked link was clicked · adds url
- UNSUBSCRIBEUnsubscribe link clicked
- SPAMMarked as spam
SEND: the email was sent. Apps usually mark the message as sent against their record.
Each event carries eventType, receiver, requestId, campaignId and your merged metadata. BOUNCED events add bounceCategory, and CLICK events add the url that was clicked.
svix-id, svix-timestamp and svix-signature headers and delivered at least once, so verify each request and deduplicate on svix-id.Read email webhooks and delivery events, then verify webhook signatures. Bounce categories are explained in the email bounce codes reference; field-level detail is in the webhook docs.
Troubleshooting
The Send Email endpoint documents four error statuses. Match the status you get to the row below.
| Status | Meaning | What to check |
|---|---|---|
400 | MeaningBad Request | What to checkThe body failed validation. The response carries a message field; read it, fix the field it names and resend. |
401 | MeaningAPI key missing or invalid | What to checkCheck the header is named X-RELAY-ACCESS-TOKEN and holds the key issued at onboarding. If the key is right, confirm the server you are calling from has an IP on your allowlist. |
405 | MeaningMethod Not Allowed | What to checkSend a POST to /email/campaign. Other methods are refused. |
412 | MeaningMetadata limits broken | What to checkThe merged metadata map has more than 10 keys, or more than 500 recipients carry their own metadata. Nothing is sent; trim the metadata and resend. |
Full error schemas are in the Send Email API reference.
FAQ
No. net/http and encoding/json from the standard library are enough. tinyRelay has no package to install.
json.Marshal escapes angle brackets and ampersands inside strings. The result is still valid JSON and decodes to the same HTML.
202, which is http.StatusAccepted in Go. The response body carries a requestId and the message SUCCESS.
Put them in metadata, at request level or per recipient. The merged map, up to 10 keys, is echoed on every webhook event for that recipient.
Next steps
- Account notification emails
- Transactional email API overview
- Email webhooks and events
- Send email with Ruby
- Send Email API reference (docs)