Every email sent through Ark goes through a series of states from initial submission to final delivery (or failure). Understanding this lifecycle helps you build robust email handling in your application.
Lifecycle Stages
┌──────────┐
┌───▶│ Bounced │ (NDR received after delivery)
│ └──────────┘
│ │
┌─────────┐ ┌──────────┐───┘ ▼
│ Pending │────▶│ Sent │ ┌─────────────────┐
└─────────┘ └──────────┘ │ Suppression List│
│ └─────────────────┘
│ ▲
├─────────────────────────▶ ┌──────────┐
│ │ HardFail │ (immediate suppression)
│ └──────────┘
│ ▲
├──────────────────────────▶┌──────┐
│ │ Held │ (manual review required)
│ └──────┘
▼
┌──────────┐ retry success ┌──────┐
│ SoftFail │─────────────────▶│ Sent │
└──────────┘ └──────┘
│
│ max retries exhausted (18 attempts)
▼
┌──────────┐
│ HardFail │ (suppression: "too many soft fails")
└──────────┘
Status Definitions
pending
The email has been accepted and is waiting to be processed. This is the initial state after a successful API call.
{
"id": "msg_abc123",
"status": "pending",
"timestampIso": "2024-01-15T10:00:00Z"
}
sent
The email has been transmitted to the recipient’s mail server. The recipient’s server accepted the message.
{
"id": "msg_abc123",
"status": "sent",
"timestampIso": "2024-01-15T10:00:02Z"
}
softfail
A temporary delivery failure occurred. Ark will automatically retry with exponential backoff up to 18 times over approximately 48 hours. If all retries fail, the message transitions to hardfail and the recipient is added to the suppression list.
{
"id": "msg_abc123",
"status": "softfail",
"timestampIso": "2024-01-15T10:00:02Z"
}
hardfail
A permanent delivery failure occurred. The email will not be retried and the recipient is immediately added to the suppression list.
Hard failures occur in two scenarios:
- Immediate rejection: The recipient’s mail server permanently rejected the email (SMTP 5xx error). Common reasons include non-existent mailbox, invalid domain, or policy rejection.
- Retry exhaustion: A soft failure that couldn’t be resolved after 18 retry attempts over ~48 hours.
{
"id": "msg_abc123",
"status": "hardfail",
"timestampIso": "2024-01-15T10:00:02Z",
"details": "550 5.1.1 The email account does not exist"
}
Immediate suppression on hard fail. When an email hard fails, the recipient is immediately added to the suppression list with reason hard fail. This prevents future delivery attempts to invalid addresses and protects your sender reputation.
bounced
A bounce notification (NDR - Non-Delivery Report) was received after the email was initially accepted. This happens when the recipient’s server accepts the message but later determines it cannot be delivered.
The recipient is immediately added to the suppression list when a bounce is received.
{
"id": "msg_abc123",
"status": "bounced",
"timestampIso": "2024-01-15T10:00:02Z",
"bounce_details": {
"original_recipient": "user@example.com",
"diagnostic_code": "smtp; 550 User not found"
}
}
held
The email is held for manual review (e.g., suspicious content detected).
{
"id": "msg_abc123",
"status": "held",
"timestampIso": "2024-01-15T10:00:00Z"
}
Bounce Types
Understanding bounce types helps you diagnose delivery issues and maintain list hygiene.
| Type | Description | Ark Behavior | Suppression |
|---|
| Hard bounce | Permanent failure - address invalid | No retry, immediate failure | Immediate (hard fail) |
| Soft bounce | Temporary failure - might succeed later | Retry up to 18 times over ~48h | After retries exhausted (too many soft fails) |
| Block | Rejected by content/spam filter | No retry | Immediate (hard fail) |
Hard Bounce Examples
| SMTP Code | Meaning | Example Message |
|---|
| 550 | Mailbox unavailable | ”User unknown”, “No such user” |
| 551 | User not local | ”User not local; please try another path” |
| 552 | Message too large | ”Message exceeds fixed maximum message size” |
| 553 | Mailbox name invalid | ”Mailbox name not allowed” |
| 554 | Transaction failed | ”Delivery not authorized, message refused” |
Soft Bounce Examples
| SMTP Code | Meaning | Example Message |
|---|
| 421 | Service unavailable | ”Try again later” |
| 450 | Mailbox busy | ”Mailbox temporarily unavailable” |
| 451 | Local error | ”Requested action aborted: local error in processing” |
| 452 | Insufficient storage | ”Mailbox full”, “Quota exceeded” |
Soft bounces convert to hard fails after 18 retries. When a soft bounce exhausts all retry attempts, the message status changes to HardFail and the recipient is added to the suppression list with reason too many soft fails. This prevents indefinite retry attempts to persistently unreachable addresses.
Tracking Email Status
Via API
Poll the email status:
import time
from ark import Ark
client = Ark()
def wait_for_final_status(email_id, max_wait_seconds=30):
start_time = time.time()
terminal_statuses = ['sent', 'hardfail', 'bounced', 'held']
while time.time() - start_time < max_wait_seconds:
email = client.emails.retrieve(email_id)
status = email.data.status
if status in terminal_statuses:
return email.data
time.sleep(1)
raise TimeoutError("Timeout waiting for final status")
# Usage
result = wait_for_final_status("msg_abc123")
print(f"Final status: {result.status}")
import Ark from 'ark';
const client = new Ark();
async function waitForFinalStatus(emailId: string, maxWaitMs = 30000) {
const startTime = Date.now();
const terminalStatuses = ['sent', 'hardfail', 'bounced', 'held'];
while (Date.now() - startTime < maxWaitMs) {
const email = await client.emails.retrieve(emailId);
const status = email.data.status;
if (terminalStatuses.includes(status)) {
return email.data;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error('Timeout waiting for final status');
}
// Usage
const result = await waitForFinalStatus('msg_abc123');
console.log(`Final status: ${result.status}`);
require "ark_email"
client = ArkEmail::Client.new
def wait_for_final_status(email_id, max_wait_seconds: 30)
start_time = Time.now
terminal_statuses = %w[sent hardfail bounced held]
while Time.now - start_time < max_wait_seconds
email = client.emails.retrieve(email_id)
status = email.data.status
return email.data if terminal_statuses.include?(status)
sleep(1)
end
raise "Timeout waiting for final status"
end
# Usage
result = wait_for_final_status("msg_abc123")
puts "Final status: #{result.status}"
import (
"context"
"errors"
"time"
"github.com/ArkHQ-io/ark-go"
)
func waitForFinalStatus(ctx context.Context, client *ark.Client, emailID string, maxWait time.Duration) (*ark.Email, error) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
email, err := client.Emails.Get(ctx, emailID)
if err != nil {
return nil, err
}
status := email.Data.Status
if status == "sent" || status == "hardfail" || status == "bounced" || status == "held" {
return email.Data, nil
}
time.Sleep(time.Second)
}
return nil, errors.New("timeout waiting for final status")
}
// Usage
result, _ := waitForFinalStatus(ctx, client, "msg_abc123", 30*time.Second)
fmt.Printf("Final status: %s\n", result.Status)
Via Webhooks (Recommended)
Receive real-time status updates:
Python (FastAPI)
Node.js (Express)
Ruby (Sinatra)
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhooks/ark")
async def handle_webhook(request: Request):
event = await request.json()
if event["event"] == "MessageSent":
print(f"Email {event['payload']['message']['token']} sent")
await update_email_status(event["payload"]["message"]["token"], "sent")
elif event["event"] == "MessageBounced":
print(f"Email bounced: {event['payload']}")
await handle_bounce(event["payload"])
elif event["event"] == "MessageDelayed":
print(f"Email delayed, will retry")
elif event["event"] == "MessageDeliveryFailed":
print(f"Email delivery failed: {event['payload']}")
return {"status": "ok"}
app.post('/webhooks/ark', async (req, res) => {
const event = req.body;
switch (event.event) {
case 'MessageSent':
console.log(`Email ${event.payload.message.token} sent`);
await updateEmailStatus(event.payload.message.token, 'sent');
break;
case 'MessageBounced':
console.log(`Email bounced: ${JSON.stringify(event.payload)}`);
await handleBounce(event.payload);
break;
case 'MessageDelayed':
console.log('Email delayed, will retry');
break;
case 'MessageDeliveryFailed':
console.log('Email delivery failed');
break;
}
res.status(200).send('OK');
});
post '/webhooks/ark' do
event = JSON.parse(request.body.read)
case event['event']
when 'MessageSent'
puts "Email #{event['payload']['message']['token']} sent"
update_email_status(event['payload']['message']['token'], 'sent')
when 'MessageBounced'
puts "Email bounced: #{event['payload']}"
handle_bounce(event['payload'])
when 'MessageDelayed'
puts 'Email delayed, will retry'
when 'MessageDeliveryFailed'
puts 'Email delivery failed'
end
'OK'
end
Retry Behavior
For temporary failures (softfail), Ark automatically retries using exponential backoff. The delay between retries follows the formula: (1.3^attempts) × 5 minutes.
| Attempt | Approximate Delay | Cumulative Time |
|---|
| 1 | 5 minutes | 5 min |
| 2 | 6.5 minutes | 12 min |
| 3 | 8.5 minutes | 20 min |
| 5 | 14 minutes | 45 min |
| 8 | 32 minutes | 1.5 hours |
| 10 | 69 minutes | 3.5 hours |
| 12 | 2.4 hours | 7 hours |
| 15 | 5.6 hours | 18 hours |
| 18 | 14 hours | ~48 hours |
After 18 failed attempts (configurable), the email is marked as hardfail and the recipient is automatically added to the suppression list with reason “too many soft fails”.
The maximum delivery attempts can be configured per installation. The default is 18 attempts, which spans approximately 48 hours of retry attempts.
State Transitions
| From | To | Trigger | Suppression Action |
|---|
| - | pending | API call accepted | - |
pending | sent | Transmitted to recipient server | - |
pending | softfail | Temporary delivery failure (4xx SMTP) | - |
pending | hardfail | Permanent delivery failure (5xx SMTP) | Automatic: hard fail |
pending | held | Held for review (suppression list, spam, etc.) | - |
softfail | sent | Retry succeeded | - |
softfail | hardfail | Max retries (18) exceeded | Automatic: too many soft fails |
sent | bounced | NDR received after delivery | None (handle via webhook) |
sent | - | Terminal state (success) | Removed if previously suppressed |
hardfail | - | Terminal state | Already suppressed |
bounced | - | Terminal state | - |
held | - | Requires manual action | - |
Hard failures result in automatic suppression. Whether the failure is immediate (SMTP 5xx error) or after retry exhaustion, the recipient is automatically added to the suppression list. Future emails to this address will be held with status “Recipient is on the suppression list” until manually removed.
Bounced status does NOT auto-suppress. When an NDR is received and the message status changes to bounced, the recipient is NOT automatically added to the suppression list. Handle the MessageBounced webhook to manually suppress if desired.
Successful delivery clears suppression. If you manually retry a message to a suppressed address and it succeeds, the address is automatically removed from the suppression list.
Deliveries Timeline
Get the delivery attempts for an email using the deliveries endpoint:
Python
Node.js
Ruby
Go
cURL
deliveries = client.emails.deliveries("msg_abc123")
for delivery in deliveries.data:
print(f"{delivery.status}: {delivery.timestamp}")
const deliveries = await client.emails.deliveries('msg_abc123');
for (const delivery of deliveries.data) {
console.log(`${delivery.status}: ${delivery.timestamp}`);
}
deliveries = client.emails.deliveries("msg_abc123")
deliveries.data.each do |delivery|
puts "#{delivery.status}: #{delivery.timestamp}"
end
deliveries, _ := client.Emails.Deliveries(ctx, "msg_abc123")
for _, delivery := range deliveries.Data {
fmt.Printf("%s: %s\n", delivery.Status, delivery.Timestamp)
}
curl "https://api.arkhq.io/v1/emails/msg_abc123/deliveries" \
-H "Authorization: Bearer $ARK_API_KEY"