Browse the docs
By language
Receive email in Go
Receive and process inbound email in Go with net/http: verify Inboundr's signed webhook and decode each message into typed structs.
This guide builds a Go server that receives every email sent to your domain, verifies it came from Inboundr, and saves its attachments. Standard library only. Go 1.22 or later.
Before you start#
- An address to receive on. Your account comes with an
instant domain such as
amber-falcon-4821.inboundr.email, so any address on it works without DNS setup. A verified domain of your own works the same way. - An API key, from API keys in the console:
bash export INBOUNDR_API_KEY="inb_xxxxxxxxxxxxxxxxxxxxxxxx" - A public HTTPS URL for your handler. Inboundr only delivers to public
addresses, so while developing, expose your local port with a tunnel:
bash ngrok http 8080 # or: cloudflared tunnel --url http://localhost:8080
1. Create an endpoint and route mail to it#
An endpoint is the URL Inboundr POSTs each email to. Create one pointing at your tunnel:
curl https://inboundr.net/api/v1/endpoints \
-H "Authorization: Bearer $INBOUNDR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Local handler", "url": "https://<your-tunnel>/webhooks/inboundr" }'The response includes a secret (whsec_…). It's shown only once, so store it
next to your API key:
export INBOUNDR_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"Then send the domain's mail to the endpoint. Find the domain's ID (the instant
domain is the one with "managed": true) and set it as the catch-all:
curl https://inboundr.net/api/v1/domains \
-H "Authorization: Bearer $INBOUNDR_API_KEY"
curl -X PATCH https://inboundr.net/api/v1/domains/dom_123 \
-H "Authorization: Bearer $INBOUNDR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "catchAllEndpointIds": ["ep_123"] }'To route a single address instead, see addresses.
2. Write the handler#
The handler gets the parsed message as JSON: from, to, recipients,
subject, text, html, attachment metadata and the spam, SPF and DKIM
verdicts. See the full payload.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
type Attachment struct {
Filename string `json:"filename"` // "" when the part has none
ContentType string `json:"contentType"`
Size int64 `json:"size"`
DownloadURL *string `json:"downloadUrl"` // nil when not stored
}
type Email struct {
ID string `json:"id"`
ThreadID string `json:"threadId"`
From string `json:"from"`
To []string `json:"to"`
Recipients []string `json:"recipients"`
Subject string `json:"subject"`
Text *string `json:"text"`
HTML *string `json:"html"`
Attachments []Attachment `json:"attachments"`
ReceivedAt time.Time `json:"receivedAt"`
}
// validSignature checks X-Inboundr-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">.
func validSignature(body []byte, header, secret string) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(kv, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return false
}
// Reject old deliveries so a captured request can't be replayed.
if age := time.Now().Unix() - t; age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
var handled sync.Map // use your database in production
func webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 64<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
if !validSignature(body, r.Header.Get("X-Inboundr-Signature"), os.Getenv("INBOUNDR_WEBHOOK_SECRET")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event struct {
Email Email `json:"email"`
}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// Retries keep their delivery ID. Recorded only after parsing succeeded,
// so a delivery that failed above is processed when it's retried.
if id := r.Header.Get("X-Inboundr-Delivery-Id"); id != "" {
if _, seen := handled.LoadOrStore(id, true); seen {
w.WriteHeader(http.StatusOK)
return
}
}
// Answer now, work in the background: slow handlers time out and get retried.
w.WriteHeader(http.StatusOK)
go processEmail(event.Email)
}
func processEmail(email Email) {
log.Printf("%s -> %s: %s", email.From, strings.Join(email.Recipients, ", "), email.Subject)
os.MkdirAll("attachments", 0o755)
for i, a := range email.Attachments {
if a.DownloadURL == nil {
continue // not stored on this plan
}
req, _ := http.NewRequest(http.MethodGet, *a.DownloadURL, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("INBOUNDR_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Print(err)
continue
}
if res.StatusCode != http.StatusOK {
log.Printf("download failed: %s", res.Status)
res.Body.Close()
continue
}
// Inline images often have no filename, and two can share one, so the
// index keeps them apart. filepath.Base: never trust a sender's name as a path.
name := fmt.Sprintf("attachment-%d", i)
if a.Filename != "" {
name = filepath.Base(a.Filename)
}
path := filepath.Join("attachments", fmt.Sprintf("%s-%d-%s", email.ID, i, name))
if f, err := os.Create(path); err == nil {
io.Copy(f, res.Body)
f.Close()
}
res.Body.Close()
}
}
func main() {
http.HandleFunc("POST /webhooks/inboundr", webhook)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}go run main.go3. Test it#
Send a sample delivery. It's signed with your endpoint's real secret, so it also tests your signature check:
curl -X POST https://inboundr.net/api/v1/endpoints/ep_123/test \
-H "Authorization: Bearer $INBOUNDR_API_KEY""ok": true means your handler answered with a 2xx. A 401 in status
means the signature didn't match: see troubleshooting.
Then send a real email, with an attachment, from your own mailbox to any
address on your domain, such as test@amber-falcon-4821.inboundr.email. It
reaches your handler within seconds.
Read mail without a server#
Webhooks need a public URL. For CLIs and cron jobs, read the inbox on demand instead. Mail is stored either way:
req, _ := http.NewRequest(http.MethodGet,
"https://inboundr.net/api/v1/emails?direction=inbound&limit=20", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("INBOUNDR_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var page struct {
Data []struct {
FromAddress string `json:"fromAddress"`
Subject string `json:"subject"`
ReceivedAt time.Time `json:"receivedAt"`
} `json:"data"`
NextCursor *string `json:"nextCursor"`
}
json.NewDecoder(res.Body).Decode(&page)
for _, e := range page.Data {
fmt.Println(e.ReceivedAt, e.FromAddress, e.Subject)
}See the emails API for filters and pagination.
Troubleshooting#
- Every delivery fails the signature check. The HMAC is computed over the
exact bytes sent. Read the raw body before any JSON parsing, and don't
re-serialize it. Also check you're using the endpoint's
whsec_…secret, not yourinb_API key. - Deliveries time out. Your handler has 30 seconds by default. Acknowledge first, then do slow work (AI calls, downloads, database writes) in the background.
- The same email arrives twice. A delivery is retried when your handler
doesn't answer
2xxin time, and a retry keeps itsX-Inboundr-Delivery-Id. Store handled IDs and skip repeats. - The body is empty. Some hosts drop request bodies. Every delivery also carries the email's ID with its own signature, so you can fetch it instead: see when the body is missing.
- Nothing arrives. Check the email under Emails in the console. If it's there, open its delivery to see your handler's response. If it isn't, the message never reached Inboundr: check the address and, for your own domain, its MX record.