PosthasteDocsGet an API key

Getting started

Go SDK

posthaste-go is the official client for Go. It has no third-party dependenciesnet/http and crypto/hmac, nothing else — so it adds nothing to your go.sum beyond itself and brings no transitive supply chain to audit. Requires Go 1.23 or newer, for the range-over-func iterators the pagination helpers return.

Install

go get github.com/posthastemail/posthaste-go

Send an email

package main

import (
	"context"
	"log"
	"os"

	posthaste "github.com/posthastemail/posthaste-go"
)

func main() {
	ph, err := posthaste.New(os.Getenv("POSTHASTE_API_KEY"))
	if err != nil {
		log.Fatal(err)
	}

	sent, err := ph.Emails.Send(context.Background(), &posthaste.SendEmailParams{
		From:           "Acme <[email protected]>",
		To:             []string{"[email protected]"},
		Subject:        "Your receipt",
		HTML:           "<p>Thanks for your order.</p>",
		Text:           "Thanks for your order.",
		IdempotencyKey: "receipt-" + orderID,
	}, nil)
	if err != nil {
		log.Fatal(err)
	}

	log.Println(sent.ID)        // msg_AZLm3kQ8T2Sf9pXbNc7HrQ
	log.Println(sent.Status)    // "queued" | "duplicate"
	log.Println(sent.Duplicate) // false — a new send, not an idempotency replay
}

context.Context is the first argument on every method, and *RequestOptions is the last. The context bounds the whole call, including the waits between retries — a cancelled context ends a backoff immediately rather than keeping a dead request alive for up to eight seconds, which is the difference between a shutdown that drains and one that hangs. The options argument carries per-call headers and a per-call timeout; nil is the ordinary case.

Status tells you which kind of success this was. A new send answers 202 with status: "queued"; a replay of the same IdempotencyKey answers 200 with status: "duplicate" and the id of the message we accepted the first time. Both are success, and the SDK surfaces the difference as sent.Duplicate rather than hiding it — if you count, bill or log per send, that distinction is the difference between an accurate number and a drifting one. It is read from the body rather than from the HTTP status, so a proxy that normalises a 202 to a 200 cannot change the answer.

IdempotencyKey is a field in the JSON body, and the SDK maps it there. The Idempotency-Key HTTP header is never read by any endpoint — see why.

Structs in, structs out

Requests are Go structs with json tags, so the compiler catches a misspelled field rather than the server catching it. Responses are decoded into structs too — every shape in the API has one, and the message statuses, event types, suppression reasons and scopes are exported as slices you can validate against.

A field the server can legitimately omit or send as null, where the difference from a zero value matters, is a pointer: Usage.DeliveryRate is *float64 because nothing sent yet is not the same claim as nought per cent delivered. Everything else is a plain type, so the common path does not read like a nil-check exercise. The open-ended blobs the API documents as objects without a fixed shape are json.RawMessage rather than map[string]any: keeping the bytes means you can decode them into your own struct, and means this SDK never has to guess at a shape it does not own.

Attachment content is a plain []byte. encoding/json base64-encodes it on the way out, which is exactly what the API expects — so there is nothing to encode by hand, and therefore nothing to double-encode, which is the mistake that corrupts a file on arrival with nothing anywhere reporting a problem.

Configuring the client

ph, err := posthaste.New(
	os.Getenv("POSTHASTE_API_KEY"),                         // required
	posthaste.WithBaseURL("https://api.posthastemail.dev"), // default
	posthaste.WithTimeout(30*time.Second),                  // per ATTEMPT, not per call
	posthaste.WithMaxRetries(2),                            // retries after the first attempt
	posthaste.WithMaxRetryDelay(time.Minute),               // longest Retry-After it will sit through
	posthaste.WithHeader("X-Trace-Id", traceID),            // added to every request
	posthaste.WithHTTPClient(myClient),                     // your own transport, pool or proxy
)
OptionNotes
apiKeyRequired, and positional. A ph_live_ key, sent as a bearer token. Server-side only — a key carries no user identity and must never reach a browser. Getting a key →
WithBaseURLDefaults to https://api.posthastemail.dev. Point it at your own deployment when self-hosting; trailing slashes are stripped.
WithTimeoutPer attempt — a call that retries twice may take longer than this. 30 seconds by default; zero disables it, leaving the context as the only bound.
WithMaxRetriesRetries after the first attempt. Two by default; 0 turns retrying off entirely.
WithHTTPClientYour own *http.Client — a proxy-aware one, a traced one, or one with a tuned connection pool.

A *Client is safe for concurrent use and is meant to be built once and shared: it holds an *http.Client, whose connection pool is the thing you want reused.

The key is never printed. It is not in fmt.Sprintf("%+v", ph), not in any error message, and not in a formatted error chain — including when a server echoes it back inside a 401. There is no exported accessor that returns it. That matters more than it sounds: a credential escapes through a debug print pasted into a ticket and through an error an aggregator uploaded far more often than through a deliberate log line.

// Behind a corporate proxy, with your own pool settings.
ph, err := posthaste.New(key, posthaste.WithHTTPClient(&http.Client{
	Transport: &http.Transport{
		Proxy:               http.ProxyURL(proxyURL),
		MaxIdleConnsPerHost: 32,
	},
}))

// The client you pass is COPIED and the copy's CheckRedirect is replaced.
// Following a redirect would re-send the Authorization header to whatever
// host the Location names. Your own client value is not mutated.

Redirects are never followed, whatever client you supply. net/http re-sends the Authorization header to a redirect target it considers the same host — and the host is decided by the Location header, which is the attacker’s input in exactly the scenario that matters. A misconfigured proxy, a hijacked DNS answer or a compromised edge could otherwise collect your API key with a single 302. A 3xx comes back to you as an error instead.

Errors

Every failure is an *Error, including the ones that never reached a server. Classify with errors.Is against the exported sentinels — that is what a Go caller reaches for, and it is harder to get wrong than a string comparison — and recover the detail with errors.As. Each error carries Status, Type, Message, Fields when the server sent them, RetryAfter when the server said, and Body for the extra keys a refusal carries.

sent, err := ph.Emails.Send(ctx, params, nil)

var phErr *posthaste.Error
switch {
case err == nil:
	record(sent.ID)

case errors.Is(err, posthaste.ErrSuppressed):
	errors.As(err, &phErr)
	// Permanent. Reason is hard_bounce | complaint | spam_trap | manual
	// | unsubscribe.
	drop(phErr.Suppression())

case errors.Is(err, posthaste.ErrDomainNotVerified):
	askThemToPublishTheDKIMRecord()

case errors.Is(err, posthaste.ErrContentBlocked):
	errors.As(err, &phErr)
	// LintCheck() names the check; LintFindings() is the whole report,
	// warnings included, so one fix pass can address everything.
	show(phErr.LintFindings())

case errors.Is(err, posthaste.ErrRateLimited):
	errors.As(err, &phErr)
	// Transient, measured in seconds. platform_paused is OUR daily ceiling,
	// not this account's — it frees on its own and no plan change touches it.
	retryAfter(phErr.RetryAfter)

case errors.Is(err, posthaste.ErrQuotaExhausted):
	// Exhausted allowance — hours or days away. Not a retry, an alert.
	alertOps(err)

default:
	errors.As(err, &phErr)
	log.Printf("posthaste refused: %d %s", phErr.Status, phErr.Type)
}

ErrRateLimited and ErrQuotaExhausted are disjoint, deliberately — neither is nested under the other. Four different refusals arrive as 429. rate_limited and platform_paused are transient and measured in seconds — the first is your request rate, the second is the platform’s own daily send ceiling, which is ours rather than yours. The other two, daily_limit_reached and monthly_limit_reached, are exhausted quota with a Retry-After measured in hours or days. If either matched the other, a branch written for a hiccup would swallow the refusal that needs a person — and telling a customer to upgrade for a ceiling that is not theirs would be wrong twice over. The error reference lists every type.

The classes do nest where the API’s vocabulary does: a suppressed recipient matches both ErrSuppressed and ErrUnprocessable, and a timeout matches both ErrTimeout and ErrConnection, so code handling the general case does not have to enumerate every specific one. ErrPosthaste matches all of them.

A refusal reason added to the API after your SDK version still arrives classified: the class is chosen from the HTTP status when the Type is unrecognised, and the Type string itself is preserved verbatim. An unhandled 500, which does not use the error envelope, becomes an ErrServer with the server’s message intact rather than an unreadable one. Two types are synthesised by the SDK and never sent by the API — connection_error (the request never opened) and timeout (it opened and never answered); both carry Status == 0, and both unwrap to the underlying transport error so errors.Is(err, context.Canceled) keeps working through the wrapper.

Retries

The SDK retries 408, 429 and 5xx, and connection failures, with exponential backoff and full jitter, honouring Retry-After. Two rules make that safe rather than merely automatic.

  • Quota is never retried in process. daily_limit_reached and monthly_limit_reached come back immediately with the wait attached, so you can queue, delay or alert instead of hammering a wall the calendar has to move before it opens.
  • Nothing is repeated that repeating could duplicate. A send is retried only when you supplied an IdempotencyKey, and Emails.SendBatch, Webhooks.Create and Streams.Create are never retried — a duplicate endpoint would receive every event twice, for ever. Reads, deletes, Domains.Create (a duplicate is a 409) and Suppressions.Create (an upsert) are all safe, and are retried.

A Retry-After longer than WithMaxRetryDelay — 60 seconds by default — is honoured by not retrying. A quota that frees at midnight is not something to block a request handler on, and sleeping through it would look like a hang, so the error comes back to you with the wait attached and you decide.

Pagination

Every list on the API pages, and every one of them takes parameters. Each resource carries three methods rather than one: List for a single page, All for a range-over-func iterator across every page, and ListAll to drain that iterator into a slice up to a ceiling you pass.

// One page.
page, err := ph.Messages.List(ctx, &posthaste.ListMessagesParams{
	Limit:  50,
	Status: "bounced",
}, nil)
// page.Data       []posthaste.MessageSummary
// page.HasMore    bool
// page.NextCursor *string — pass as Before on the next request

// Every page, as an ordinary for loop. Breaking out stops fetching.
for message, err := range ph.Messages.All(ctx, &posthaste.ListMessagesParams{
	Status: "bounced",
}, nil) {
	if err != nil {
		return err
	}
	suppress(message.To)
}

// Or into a slice, with a ceiling you choose.
recent, err := ph.Messages.ListAll(ctx, nil, 500, nil)

// EVERY list has the same three, including the ones that send no hasMore at
// all — domains, webhooks, API keys and invoices. This is all sixty invoices,
// not the first fifty.
invoices, err := ph.Billing.ListAllInvoices(ctx, nil, posthaste.DefaultMaxItems, nil)

// Billing history is the odd one out: it pages FORWARD from a sequence number
// in `after`, oldest first. Hence the different names — the helper knows.
for event, err := range ph.Billing.AllHistory(ctx, nil, nil) {
	if err != nil {
		return err
	}
	record(event.Seq, event.Type)
}

All returns an iter.Seq2[T, error], so an ordinary for loop holds one page in memory at a time and break stops fetching — there is no goroutine behind it, so nothing leaks when you leave early. An error inside the walk is yielded rather than swallowed, and ListAll returns the items it had gathered alongside it: a caller must never mistake “we stopped” for “that was all of them”. The ceiling on ListAll is not garnish either — draining an unbounded list into memory is how a convenience helper becomes an incident on the one account with four million messages, so the caller chooses the bound.

Neither stopping rule is correct on its own, which is why the helper exists. for cursor != "" never terminates on the endpoints that return a non-null cursor on their last page: the loop asks for the page after the last one, gets an empty page carrying the same cursor, and spins for ever without erroring. And if !page.HasMore { break } stops after ONE page on /v1/domains, /v1/webhooks, /v1/api-keys, /v1/billing/invoices and /v1/billing/history, because those send no hasMore at all — the field decodes to Go’s zero value, false, which looks exactly like “that was everything”. All consults both signals and treats each as authoritative only where the server actually sends it. The full explanation →

Verifying webhooks

VerifyWebhook parses the signature header by key rather than positionally (v1= exists so a v2= can be added beside it), compares in constant time with hmac.Equal, and rejects a timestamp more than 300 seconds old or more than 300 seconds in the future — a future timestamp is not clock skew to be generous about, it is an attacker buying an unlimited replay window.

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	// THE RAW BYTES. json.NewDecoder(r.Body) would hand you an object, and
	// the signature covers the bytes we sent — not the object they decode to.
	raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	ok, reason := posthaste.VerifyWebhook(
		raw,
		r.Header.Get(posthaste.SignatureHeader),
		os.Getenv("POSTHASTE_WEBHOOK_SECRET"),
		nil,
	)
	if !ok {
		// malformed_header | unsupported_version | timestamp_too_old
		// | timestamp_in_future | signature_mismatch
		log.Printf("rejected webhook: %s", reason)
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	var event posthaste.WebhookEvent
	if err := json.Unmarshal(raw, &event); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// Deduplicate on the delivery id — a retry is not a second event.
	enqueue(r.Header.Get(posthaste.DeliveryIDHeader), event)

	// Acknowledge fast; do the work elsewhere. We time out after 10 seconds.
	w.WriteHeader(http.StatusNoContent)
}

Verify over the raw bytes. A body that has been decoded and re-encoded will never verify — json.Unmarshal followed by json.Marshal is not a byte-level round trip, and the signature covers the bytes we sent, not the object they decode to. Any framework helper that binds the body for you (c.Bind, ShouldBindJSON, a decoding middleware) also consumes the stream, so read the bytes first and decode from them. This is the single most common reason verification “mysteriously” fails, and no amount of correct key handling rescues it.

ParseWebhookEvent verifies and decodes in one step, returning an error that names the reason, for the common case where a bad delivery just gets a 400. Use VerifyWebhook directly when you want to branch on the reason. The signing secret is the one returned once when the endpoint was created — webhooks in full →

The Go verifier is tested against signatures produced by the signer that signs your deliveries, not by a second copy of itself — the same checked-in fixtures the Python SDK verifies against, generated from the real implementation. A verifier that only agrees with its own signing code would pass every test while failing the one case that matters.

Every method

Account.Me / Verify / Usage                       GET    /v1/me, /v1/account/verify, /v1/usage

Domains.Create(ctx, name, opts)                   POST   /v1/domains
Domains.List / All / ListAll                      GET    /v1/domains
Domains.Verify(ctx, id, opts)                     POST   /v1/domains/:id/verify
Domains.Delete(ctx, id, opts)                     DELETE /v1/domains/:id
Domains.Setup(ctx, id, opts)                      GET    /v1/domains/:id/setup
Domains.ConnectCloudflare(ctx, id, params, opts)  POST   /v1/domains/:id/cloudflare
Domains.DisconnectCloudflare(ctx, opts)           DELETE /v1/account/cloudflare

Emails.Send(ctx, params, opts)                    POST   /v1/emails
Emails.SendBatch(ctx, messages, opts)             POST   /v1/emails/batch
Emails.CancelSchedule(ctx, id, opts)              DELETE /v1/emails/:id/schedule

Messages.List / All / ListAll                     GET    /v1/messages
Messages.Get(ctx, id, opts)                       GET    /v1/messages/:id
Messages.DownloadAttachment(ctx, id, att, opts)   GET    /v1/messages/:id/attachments/:id
Messages.Stats(ctx, params, opts)                 GET    /v1/stats/messages

Suppressions.List / All / ListAll                 GET    /v1/suppressions
Suppressions.Create(ctx, address, detail, opts)   POST   /v1/suppressions
Suppressions.Delete(ctx, address, opts)           DELETE /v1/suppressions/:address

Webhooks.Create(ctx, url, eventTypes, opts)       POST   /v1/webhooks
Webhooks.List / All / ListAll                     GET    /v1/webhooks
Webhooks.Delete(ctx, id, opts)                    DELETE /v1/webhooks/:id

Templates.List / Get / Versions / Preview         GET    /v1/templates…
Templates.Create / Update / Delete                POST   /v1/templates…

Streams.List(ctx, opts)                           GET    /v1/streams
Streams.Create(ctx, slug, name, opts)             POST   /v1/streams

APIKeys.List / All / ListAll                      GET    /v1/api-keys

Billing.Get(ctx, opts)                            GET    /v1/billing
Billing.History / AllHistory / ListAllHistory     GET    /v1/billing/history
Billing.Invoices / AllInvoices / ListAllInvoices  GET    /v1/billing/invoices
Billing.Invoice(ctx, id, opts)                    GET    /v1/billing/invoices/:id

The surface matches the TypeScript and Python SDKs method for method, so a team running more than one does not have to hold several mental models. Every list appears three times, because List returns one page and the other two return all of them; the ones on Billing are named for their list rather than for the resource — ListAllInvoices and ListAllHistory — since one resource owns two. Templates.List and Streams.List take no pagination arguments because those endpoints do not page, and inventing an All for them would imply a cursor that does not exist.

Deliberately absent: creating and revoking API keys, and everything else that requires a signed-in person rather than a key — checkout, plan changes, profile edits, team management. Those endpoints refuse a bearer token outright, and a method that can only ever return ErrPermissionDenied is worse than no method. The platform operator API under /admin/ is not part of the public surface at all.

NextMCP serverGive an AI agent the ability to send mail and read delivery status — scoped keys decide which tools exist, and a send is previewable before it happens.