A reliable email api integration is mostly about what happens when something goes wrong. The happy path is one HTTP request. The hard cases are a timeout where you do not know whether the message was accepted, a worker that crashes halfway through a batch, and a retry loop that turns a short outage into a flood. This guide gives us a set of patterns that keep every email sent exactly once, or at worst predictably once, whichever provider endpoint you call.
We describe the patterns generically so they work with any HTTP email API, including MigoSMTP. Where a specific feature matters, such as an idempotency header, a batch endpoint or a rate limit, check your MigoSMTP account or API documentation for exact details rather than assuming. If you are still choosing between protocols, read SMTP or API: how to choose first.
Why email sends fail in ways other API calls do not
Most API calls can be repeated without harm. Email cannot. A second call to create a user usually fails with a conflict, but a second call to send a receipt cheerfully sends a second receipt. The customer sees two messages, support sees a complaint, and a password reset sent twice can invalidate the first code the person is reading.
The core problem is the unknown outcome. Your client sends the request, the server accepts it, and the response is lost to a network drop or a timeout. From your side this looks identical to a request that never arrived. If you retry, you may duplicate. If you do not retry, you may lose the message. Every pattern below exists to resolve that ambiguity.
| Failure | What you know | Risk if handled naively |
|---|---|---|
| Connection refused or DNS failure | The request never reached the API | Safe to retry; risk is only a retry storm |
| Read timeout after the request was sent | Nothing about acceptance | Retry without a dedupe key can double send |
| 429 Too Many Requests | The API rejected the call for pacing reasons | Immediate retry makes the pressure worse |
| 5xx from the API or a gateway | Usually not accepted, but not guaranteed | Same ambiguity as a timeout in some cases |
| 400 or 422 validation error | The request itself is wrong | Retrying forever wastes capacity and hides a bug |
| Worker crash after the API call | Accepted, but you never recorded it | Job is redelivered and the email is sent again |
Idempotency keys and duplicate-send prevention
An idempotent operation produces the same result however many times you perform it. Many HTTP APIs let you pass an idempotency key, commonly in a header, so a repeated request with the same key returns the original result instead of creating a new resource. The IETF has an active draft for a standard Idempotency-Key header field, which shows how widely the idea is used. Whether MigoSMTP supports such a header, and for how long a key is remembered, must be confirmed in the MigoSMTP API documentation. We do not assume it here.
The good news is that you do not have to wait for server support. You can build the same guarantee on your side with a dedupe table. The key idea is that your application decides the identity of an email before it talks to the network.
Choose a deterministic dedupe key
A good key is derived from the business event, not generated at send time. Use something like order-1042-shipped or user-77-password-reset-2026-10-15T09:14. A random UUID created inside the worker does not help, because a redelivered job would create a new one. Include the message type so that a receipt and a shipping notice for the same order do not collide, and include a time bucket or counter only when repeated sends are legitimate, for example a daily digest.
Enforce it with a unique constraint
Application checks such as look up, then insert are racy. Put a unique index on the key and let the database settle ties. The first writer wins and creates the send record. Any later attempt either gets a constraint error or finds the existing row and reads its state.
CREATE TABLE email_sends (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
dedupe_key VARCHAR(190) NOT NULL,
to_address VARCHAR(320) NOT NULL,
template VARCHAR(80) NOT NULL,
payload_json JSON NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
next_try_at DATETIME NULL,
message_id VARCHAR(255) NULL,
last_error TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uq_dedupe (dedupe_key)
);
The statuses we use are pending, sending, accepted, failed and unknown. Accepted means the API returned success and you stored the message ID. Unknown means a call was made and the outcome is not certain, which we cover in the retry section. If the API does support an idempotency key, pass the same dedupe key there as well, so both layers agree. Treat server-side support as a second safety net, not a replacement.
The outbox pattern: send only what you committed
The classic bug looks like this: your code saves an order, then calls the email API, then returns. If the API call fails, the order exists with no email. If you call the API first and the database commit fails, the customer gets a confirmation for an order that does not exist. Two systems cannot be updated in one transaction, so we change the problem.
With the outbox pattern, the email request is just another row written in the same transaction as the business change. Either both are committed or neither is. A separate process then delivers outbox rows to the API. Delivery becomes a retryable background job, and the database is the single source of truth about what must be sent.
- Transaction: insert the order and the email_sends row together, with status pending.
- Commit: only after commit does anything become visible to the sender process.
- Claim: a worker selects pending rows that are due and marks them sending, using row locking so two workers never take the same row.
- Call the API: send the request, then record the result and message ID on the row.
- Reconcile: a periodic job looks for rows stuck in sending or unknown and resolves them.
You can feed the worker by polling the table every few seconds, or by publishing to a queue after commit. Polling is simpler and has fewer moving parts. A queue helps when volume is high, but keep the table as the record of truth, because queues can redeliver or drop messages and the table lets you ask what happened to this email.
Queueing in your app: workers, concurrency and ordering
Do not call the email API inside the web request unless you have a strong reason. A slow response from any third party then becomes a slow checkout, and a spike in sign-ups becomes a spike in open connections. Return to the user as soon as the outbox row is committed, and let background workers do the delivery.
- Bound concurrency. A fixed number of workers, each with a small HTTP connection pool, gives you a predictable ceiling on requests per second. Unbounded parallelism is how an app accidentally hits an API limit.
- Separate queues by urgency. Password resets and one-time codes should never wait behind a newsletter. Use distinct queues or priorities, as discussed in separating transactional and marketing streams.
- Make jobs idempotent. Assume every job can be delivered twice. The first step of a job is to load the row and exit if the status is already accepted.
- Do not rely on ordering. Retries and parallel workers reorder messages. If order matters, for example verification before welcome, model it as a dependency in your data, not as queue order.
- Expire stale mail. A login code that is delivered after twenty minutes is worse than none. Give time-sensitive rows an expiry and mark them expired instead of sending.
Keep payloads small. Store the template name and variables, or the final rendered content, but not large attachments inside queue messages. Reference stored files by ID, in line with the limits explained in our size limits and MIME guide.
Timeouts and retry classification
Every HTTP call needs explicit timeouts. A client with no timeout will wait far longer than a human cares about, and will hold a worker slot the entire time. Set a short connect timeout, often a second or two, and a total timeout long enough for a normal response but short enough to fail fast. Pick the values from your own measured latency and the guidance in your MigoSMTP API documentation, not from a number in a blog post.
When a call ends, put the outcome into one of three buckets. This classification is the heart of the integration.
| Outcome | Examples | Action |
|---|---|---|
| Accepted | 2xx with a message ID | Store the ID, mark accepted, stop |
| Definitely rejected, do not retry | 400, 401, 403, 404, 422, other 4xx that describe your request | Mark failed, record the error body, alert if it repeats; fix code, credentials or data |
| Retry later, same dedupe key | 429, 500, 502, 503, 504, connect errors, DNS errors | Back off and retry; honour Retry-After |
| Unknown outcome | Read timeout, connection reset after sending | Retry only with the same dedupe key, or reconcile first |
HTTP semantics for these codes, including the Retry-After header, are defined in RFC 9110, and RFC 6585 defines 429. Note the difference from SMTP. In SMTP, 4xx replies are temporary and 5xx are permanent, which we cover in our guide to SMTP bounce codes. In an HTTP API, the roles are different: 5xx means the service had a problem and 4xx means your request had one. Do not mix the two models. An HTTP 400 is never a temporary mail failure.
Backoff with jitter
Retry delays should grow exponentially and include randomness, so that a thousand workers that failed together do not all come back together. A common approach is a base delay doubled on each attempt, capped at a maximum, with a random fraction applied. If the response includes Retry-After, use it as the minimum. Set a maximum number of attempts or a maximum age, after which the row moves to failed and raises an alert. Our guide on rate limits, retries and backoff goes deeper on the strategy.
import random, time
RETRY_STATUS = {429, 500, 502, 503, 504}
def next_delay(attempt, retry_after=None, base=2.0, cap=300.0):
delay = min(cap, base * (2 ** attempt))
delay = random.uniform(delay / 2, delay)
if retry_after is not None:
delay = max(delay, float(retry_after))
return delay
def deliver(row, client):
try:
resp = client.post("/send", json=row.payload,
headers={"X-Dedupe-Key": row.dedupe_key},
timeout=(2, 15))
except (ConnectionError, TimeoutError):
return ("retry", next_delay(row.attempts))
if 200 <= resp.status_code < 300:
return ("accepted", resp.json().get("id"))
if resp.status_code in RETRY_STATUS:
ra = resp.headers.get("Retry-After")
return ("retry", next_delay(row.attempts, ra))
return ("failed", resp.text[:500])
The header name in this example is invented for illustration. Use whatever the MigoSMTP API documentation specifies, or rely on your own table alone if no such header exists.
Response handling and storing message IDs
A success response is not the end of the story, it is the start of tracking. Store the message ID from the response on your send record immediately, in the same step that marks the row accepted. Then webhook events for delivery, bounce and complaint can be joined to it. The reliability of that second half is covered in handling email webhooks reliably.
- Keep the raw API response body for a short period. It is invaluable when debugging.
- Record timestamps for created, first attempt, accepted and last event. Gaps between them show where delays occur.
- Understand what accepted means. A 2xx response from an email API normally means the service queued the message for delivery, not that the recipient server accepted it. Final delivery is reported later by events.
- Do not parse error messages as logic. Use status codes and any documented error codes, and treat free text as information for humans.
- Index the message ID column so support can search from an ID in a log to a customer record in one query.
Redact secrets and personal data in logs. Log the dedupe key, send ID, status and message ID, but not API keys or full message bodies. Keys should be stored and rotated as described in API key security.
Batching and bulk sends
Sending to many recipients is a different workload from sending one receipt. You are now managing throughput, memory and fairness to other traffic. Some email APIs offer a batch endpoint that accepts many messages in one request, and others expect one message per request with many concurrent calls. Check your MigoSMTP account or API documentation for what is available and for per-request recipient limits before designing around either.
- Chunk the audience. Read recipients in pages from the database, not as one giant list in memory. Create send rows per recipient, then let workers process them.
- Personalise per recipient. Avoid putting many recipients in To or Cc. Each person should get their own message, with their own unsubscribe link and tracking data.
- Pace the send. Spread large campaigns over a window rather than releasing everything at once. Pacing also protects your reputation, especially on new domains, as described in the warm-up guide.
- Make the campaign resumable. Because each recipient has a row with a unique dedupe key such as campaign-318-user-9001, you can stop and restart a send at any point without double sending.
- Handle partial failure. If a batch call can succeed for some messages and fail for others, store per-message results. Never mark a whole batch accepted or failed on a single status code unless the documentation says that is how it works.
Do not skip list basics: Bulk sending through an API does not change the rules for consent, suppression and unsubscribe. Filter suppressed addresses before creating send rows, using the approach in our suppression list guide.
Worked example: one outage, two designs
Here is an invented example with round numbers. An online shop sends an order confirmation for every order. On a busy afternoon it creates 6,000 orders in an hour, and the email API has a 4-minute period of slow responses and intermittent 503 errors.
| Step | Naive design | Outbox with dedupe key |
|---|---|---|
| Order created | Request calls the API directly | Request writes order and send row, returns at once |
| Slow API for 4 minutes | Checkout waits on a long timeout, some customers see errors and click Pay again | Checkout unaffected; rows wait in pending |
| Timeout on 150 calls | Code retries immediately; many of those messages had already been accepted | Rows marked unknown; retried later with the same dedupe key |
| Result | 150 orders with roughly 2 confirmations each, plus duplicate orders from double clicks | About 150 messages delayed by a few minutes; zero duplicates |
| Support impact | Dozens of tickets asking which email is correct | None; queue age alert fired and cleared |
With 6,000 orders in the hour, the average is 100 per minute, so a 4-minute slowdown leaves a backlog of roughly 400 rows. If the workers can send 300 per minute once the API recovers, the backlog clears in about 2 minutes (400 rows divided by the 200 per minute of spare capacity beyond the 100 per minute of new arrivals). That arithmetic is worth doing for your own system: the spare capacity of your workers decides how fast you recover, and the cap on concurrency decides whether recovery stays within the limits of your account.
Rate-limit awareness
Rate limits come in forms such as requests per second, messages per day, concurrent connections and recipients per request. Which ones apply to your account, and their exact values, are in your MigoSMTP account or API documentation. Do not guess them. Build your client so that the numbers are configuration, not constants in code.
- Use a token bucket or a fixed delay between requests in each worker, with a global cap across all workers.
- On 429, stop sending from that worker for the Retry-After period, or a backoff delay if none is given. Do not just retry the same call.
- Watch for slow success. A rising latency trend often arrives before errors, and is a cue to reduce concurrency.
- Use a circuit breaker. After a run of failures, pause calls for a short window, then probe with one request before resuming at full speed.
- Keep the limits of the receiving side in mind. Provider acceptance is only the first hop. Mailbox providers apply their own limits, covered in our bulk sender requirements guide.
Graceful degradation when the API is down
Any external service will be unavailable sometimes. Decide in advance what your product does, so the answer is not improvised during an incident.
| Email type | Suggested behaviour during an outage |
|---|---|
| Password reset, one-time code | Queue with a short expiry; show on screen that the message may be delayed and offer resend after a cool-down |
| Order or payment confirmation | Queue and deliver late; also show the confirmation on the order page |
| Account notifications | Queue and deliver late; consider combining several into one |
| Newsletters and campaigns | Pause the campaign and resume later; never rush to catch up |
| Internal alerts | Fall back to another channel such as chat or a second provider |
Set alerts on queue depth and on the age of the oldest pending row, not just on error counts. A growing queue with no errors still means customers are waiting. Set a cap on the queue so memory and storage cannot grow without limit, and decide what gets dropped first if the cap is reached. If you design a second provider as a fallback, remember the work involved in warming up and authenticating a second path, as described in our migration guide.
Testing the integration
Tests that only check a 200 response will not protect you. The important behaviour is what happens on failure, so make failure easy to produce. Run your client against a mock server or sandbox that can be told to respond slowly, return 429 with Retry-After, return 503, drop the connection after reading the request, or return a malformed body. Use a mail catcher for content checks, following our staging environment guide.
- Duplicate job test: enqueue the same job twice and assert that only one request reaches the mock API.
- Crash test: kill a worker after the API call and before the database update, then assert the reconciler resolves the row without a second send.
- Timeout test: make the mock accept the request and then stall, and check the row ends as accepted exactly once.
- Poison message test: send a payload that returns 422 and confirm it moves to failed after one attempt, with the error stored.
- Load test: run a realistic burst against staging to confirm concurrency caps and backoff behave as designed.
Pre-launch checklist
- Every email has a deterministic dedupe key and a unique index enforces it.
- Send rows are written in the same transaction as the business event (outbox).
- Idempotency header support is confirmed in the MigoSMTP API documentation, and our own table is used regardless.
- Workers, not web requests, call the API, with bounded concurrency.
- Connect and total timeouts are set on every call.
- Responses are classified: accepted, retry, failed, unknown. Other 4xx responses are never retried blindly.
- Backoff has jitter, a cap, a maximum attempt count and honours Retry-After.
- Message IDs are stored on send rows and indexed.
- Bulk sends are chunked, paced and resumable, with suppression filtering before row creation.
- Rate limits are configuration, taken from your account or API documentation.
- Alerts exist for oldest pending row age, failed count and 429 rate.
- Failure tests for duplicates, crashes, timeouts and 429 pass in staging.
Want a second pair of eyes on your sending design? Tell us how your application sends email today and where retries or duplicates worry you, and we will help you plan a safe integration.
Further reading and sources
Frequently asked questions
What is an idempotency key for an email API?
It is a unique value you attach to a request so that the server can recognise a repeat of the same request and avoid doing the work twice. For email, it means a retry after a timeout does not produce a second copy of the message. Whether and how MigoSMTP supports an idempotency header must be confirmed in the MigoSMTP API documentation. Either way, you can get the same protection with your own dedupe table.
If the API supports idempotency, do I still need my own dedupe table?
Yes, we recommend it. Server-side idempotency usually covers only a limited window and only requests that reach the API. Your own table also stops duplicate jobs, duplicate webhook triggers and double clicks upstream, and it gives you an audit trail of what you intended to send.
Is it safe to retry after a timeout?
Only if you have a dedupe strategy. A timeout means you do not know whether the API accepted the message. Without an idempotency key or a lookup by your own reference, a retry can send the email twice. With one, a retry is safe.
Which HTTP status codes should I retry?
Retry network failures, timeouts, 429 Too Many Requests and 5xx server errors, using exponential backoff with jitter and honouring any Retry-After header. Do not retry other 4xx errors such as 400, 401, 403 or 422, because the same request will fail the same way until you change something.
What is the outbox pattern?
You write the email request to a table in the same database transaction as the business change, then a separate process reads unsent rows and calls the API. It removes the gap where the database commit succeeds but the email call fails, or the other way round.
Should I send emails from the web request or from a queue?
From a queue for almost everything. A queue protects user-facing latency, lets you retry safely and lets you control rate. The exception is a very simple internal tool where a failed send can just be shown to the person who triggered it.
How should I send a large campaign through an API?
Split recipients into batches, cap concurrency, respect documented limits and Retry-After, and spread sending over time. Check your MigoSMTP account or API documentation for exact limits and whether a batch endpoint exists. For new domains or IPs, follow a warm-up plan as well.
Why store the message ID returned by the API?
It is the join key between your send record and later events such as delivery, bounce and complaint webhooks. Without it, support questions like did this customer get the receipt become guesswork.
How do I test an email integration without sending real mail?
Run the API client against a mock server or sandbox that can return slow responses, 429 and 5xx on demand, and use a mail catcher for content checks. Assert on your send table, not just on HTTP calls. Our staging guide describes safe setups.