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:

bash
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:

bash
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:

bash
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.

main.go
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))
}
bash
go run main.go

3. Test it#

Send a sample delivery. It's signed with your endpoint's real secret, so it also tests your signature check:

bash
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:

go
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 your inb_ 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 2xx in time, and a retry keeps its X-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.

Next steps#