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
The four calls
| What | Call |
|---|---|
| Read the conversation | GET /v1/threads/{id} |
| Look up the sender | GET /v1/contacts?lookup={email} |
| Reply in thread | POST /v1/messages/{id}/reply-all |
| Hand to a human | POST /v1/messages/{id}/archive with unread |
Everything else in the REST chapter is refinement. These four, plus a webhook endpoint, are an agent.
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
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']
endPHP
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.
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.jsonwill 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