No SDK for your language, and it does not matter

There are SDKs for TypeScript and Python, and the API underneath them is ordinary HTTP with a bearer token. If your service is Go, Ruby, PHP, Elixir, or anything else, you are not missing much: four calls cover a working agent, and the formal description at [openapi.json](/openapi.json) generates a client if you want one.

3 min read

One HTTP API reached from several languages
Four calls, a bearer token, and whatever language you already run.

The four calls

WhatCall
Read the conversationGET /v1/threads/{id}
Look up the senderGET /v1/contacts?lookup={email}
Reply in threadPOST /v1/messages/{id}/reply-all
Hand to a humanPOST /v1/messages/{id}/archive with unread

Everything else in the REST chapter is refinement. These four, plus a webhook endpoint, are an agent.

Go

Go
type reply struct {
	Body string `json:"body"`
}

func replyAll(ctx context.Context, messageID, body string) error {
	payload, _ := json.Marshal(reply{Body: body})
	req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://mcpmailer.com/v1/messages/"+messageID+"/reply-all",
		bytes.NewReader(payload))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("MCPMAILER_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	var out struct {
		Status     string `json:"status"`
		Reason     string `json:"reason"`
		RetryAfter int    `json:"retryAfter"`
	}
	json.NewDecoder(res.Body).Decode(&out)

	// A refusal is an answer with a reason, not a transport failure.
	if out.Status == "rejected" {
		return fmt.Errorf("send refused: %s (retry after %ds)", out.Reason, out.RetryAfter)
	}
	return nil
}

Ruby

Ruby
require 'net/http'
require 'json'

def reply_all(message_id, body)
  uri = URI("https://mcpmailer.com/v1/messages/#{message_id}/reply-all")
  req = Net::HTTP::Post.new(uri)
  req['Authorization'] = "Bearer #{ENV.fetch('MCPMAILER_API_KEY')}"
  req['Content-Type'] = 'application/json'
  req.body = { body: body }.to_json

  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
  out = JSON.parse(res.body)

  raise "send refused: #{out['reason']}" if out['status'] == 'rejected'
  out['messageId']
end

PHP

PHP
function reply_all(string $messageId, string $body): string {
    $ch = curl_init("https://mcpmailer.com/v1/messages/$messageId/reply-all");
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('MCPMAILER_API_KEY'),
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode(['body' => $body]),
    ]);

    $out = json_decode(curl_exec($ch), true);
    curl_close($ch);

    if (($out['status'] ?? '') === 'rejected') {
        throw new RuntimeException('send refused: ' . $out['reason']);
    }
    return $out['messageId'];
}

The three things to get right

Language does not change these, and they are what actually breaks.

Verify the webhook signature over the raw body. Compute an HMAC-SHA256 with your signing secret and compare in constant time, before parsing. Every language has this in its standard library, and every framework has a way to get the unparsed body that you have to look up once.

Be idempotent on the message id. Deliveries retry, so a handler without a handled-message record will eventually reply twice. Whatever your storage is, this is one table and one check, per webhooks or polling.

Read the refusal reason. daily_send_quota_exhausted and monthly_send_quota_exhausted carry a reset, recipient_suppressed means stop, monthly_spend_cap_reached needs the workspace owner, and a 429 carries retry-after. Treating any of them as a generic error means retrying into a limit that exists to protect your domain.

Signature, idempotency, and refusal handling as the three constants
Three things that break, in every language equally.

Generating a client instead

openapi.json is OpenAPI 3.1 and complete enough to generate a typed client in most languages. That is often the better route for a larger codebase: you get the whole surface, request and response types, and something that fails at compile time when you misspell a field.

The trade is that generated clients tend to model errors as exceptions, which is exactly wrong for refusals here. Whatever you generate, wrap the send so a rejected status comes back as a value your code branches on rather than as something thrown.

What about MCP

MCP is for letting a model call tools directly, and there are servers and clients in several languages. If your agent is a model deciding what to do, that path is worth taking, per what an MCP email server is. If your code already knows what to do and just needs to send, REST is simpler and always available.

Most real deployments end up with both, which costs nothing: same key, same limits, same audit trail.

Questions

Do I need an official SDK?
No. Four HTTP calls with a bearer token cover a working agent, and openapi.json will generate a typed client if you want the full surface.
What are the four calls?
Get the thread, look up the contact, reply in thread, and mark unread to hand over. A webhook endpoint completes the loop.
What breaks most often in a hand-rolled client?
Webhook signature verification against a parsed body rather than the raw bytes, missing idempotency, and treating a refusal as a generic error and retrying it.
How do I handle refusals in a generated client?
Wrap the send. Generated clients usually throw on non-success, but a rejected send carries a reason your code should branch on rather than raise.
Can I use MCP from any language?
Where a client exists for your stack, yes. If your code already knows what to do rather than a model deciding, REST is simpler and always available.
Does the API differ from the SDKs?
No. The SDKs are convenience over the same endpoints, with identical keys, limits, quotas, and audit trail.

Give your agent an address it can answer from.

Create an inbox