Introduction
The utobo Email API lets you send transactional and marketing emails from any app, script, or automation tool. It is a simple HTTP REST API — you send a JSON body, you get back a JSON response with an email ID.
utobo Email is built on AWS SES, which means industry-leading deliverability at a fraction of the cost of competitors like Mailchimp or Resend.
Base URL
httphttps://api.email.utobo.comℹ Note
All requests must use HTTPS. HTTP requests are rejected.ℹ Note
The legacy path/functions/v1/send-email still works and will keep working — existing integrations need no change. New integrations should use /v1/emails.Response format
All responses are JSON. A successful send returns 200; errors return an appropriate 4xx or 5xx status. The transactional send API returns a stable, machine-readablename alongside a human message — always branch on name, never on the message text. (The dashboard-only management endpoints currently return just an error message string.)
json// Success — POST /v1/emails
{ "id": "3c9b0f4e-1a2b-...", "message": "Email sent." }
// Error — POST /v1/emails
{
"error": "The 'from' domain is not verified.",
"name": "domain_not_verified",
"message": "The 'from' domain is not verified."
}Quick start (5 minutes)
From zero to sending your first email in 5 steps:
Create your utobo Email account
Sign up at email.utobo.com. Your organization is created automatically.
Sign up →Verify a sending domain
Go to Account → Domains and add your domain (e.g. yourcompany.com). Add the TXT/CNAME records shown in your DNS provider. This usually takes under 5 minutes.
Create an API key
Go to Account → API Keys and click Create API key. Copy the key — it's shown only once.
Install the SDK (optional)
Use the official Node.js SDK for the best experience, or call the API directly with any HTTP client.
npm install utobo-emailSend your first email
Call the API with your key and verified domain:
import { UtoboEmail } from 'utobo-email';
const client = new UtoboEmail('ut_live_your_api_key_here');
const { data, error } = await client.emails.send({
from: 'Hello <hello@yourverifieddomain.com>',
to: 'customer@example.com',
subject: 'Welcome!',
html: '<p>Thanks for signing up.</p>',
});
console.log(data?.id); // "3c9b0f4e-..."Authentication
Every API request must include your API key in the Authorization header as a Bearer token.
httpAuthorization: Bearer ut_live_your_api_key_hereAPI key format
All keys start with ut_live_ followed by 64 hexadecimal characters (32 random bytes). Keys are shown once on creation — store them immediately in your environment variables.
bash# Store your key as an environment variable (never hardcode it)
export UTOBO_EMAIL_API_KEY="ut_live_a1b2c3d4e5f6..."Key permissions
| Permission | What it can do | Recommended for |
|---|---|---|
| Send only | Send emails, retrieve email status | Production apps |
| Full access | Send + manage contacts, lists, domains | Admin scripts, internal tools |
⚠ Warning
Never expose your API key in client-side browser code, public GitHub repos, or anywhere else that is publicly accessible. Use environment variables or a secrets manager.Send your first email
A minimal working example across all supported languages:
Node.js (SDK)
javascriptimport { UtoboEmail } from 'utobo-email';
const client = new UtoboEmail(process.env.UTOBO_EMAIL_API_KEY);
const { data, error } = await client.emails.send({
from: 'Acme <noreply@acme.com>',
to: 'customer@example.com',
subject: 'Welcome to Acme!',
html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
});
if (error) {
console.error('Send failed:', error.message);
} else {
console.log('Email ID:', data.id);
}cURL
bashcurl -X POST https://api.email.utobo.com/v1/emails \
-H "Authorization: Bearer $UTOBO_EMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.com>",
"to": ["customer@example.com"],
"subject": "Welcome to Acme!",
"html": "<h1>Welcome!</h1><p>Thanks for signing up.</p>"
}'A successful request returns HTTP 200 with a JSON body containing the email id. Use this ID to retrieve the delivery status later.
json{ "id": "3c9b0f4e-1a2b-4c3d-8e5f-6a7b8c9d0e1f", "message": "Email sent." }Send email
/v1/emailsSends a transactional or marketing email to one or more recipients.
Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| from | string | ✓ | Sender address. Use 'Name <email>' format for display name. Must be from a verified domain. |
| to | string | array | ✓ | Recipient address(es). Maximum 50 addresses total across to, cc, and bcc. |
| subject | string | ✓ | Email subject line. |
| html | string | — | HTML body of the email. Required if text is not provided. |
| text | string | — | Plain-text body. Required if html is not provided. Used as fallback by email clients. |
| reply_to | string | array | — | Reply-to address(es). Recipients will reply to this address instead of from. |
| cc | string | array | — | CC recipients. Counts toward the 50-recipient limit. |
| bcc | string | array | — | BCC recipients. Counts toward the 50-recipient limit. Invisible to other recipients. |
| headers | object | — | Custom email headers as key-value pairs. e.g. { "X-Order-ID": "12345" } |
Example request
jsonPOST /v1/emails
Authorization: Bearer ut_live_your_api_key_here
Content-Type: application/json
{
"from": "Acme Support <support@acme.com>",
"to": ["alice@example.com", "bob@example.com"],
"cc": "manager@acme.com",
"subject": "Your order #4521 has shipped",
"html": "<p>Your order is on the way! Track it at <a href='...'>acme.com/track</a></p>",
"text": "Your order is on the way! Track it at acme.com/track",
"reply_to": "orders@acme.com",
"headers": { "X-Order-ID": "4521" }
}Success response — HTTP 200
| Field | Type | Description |
|---|---|---|
| id | string | Unique email ID (UUID). Use with GET /v1/emails/:id to retrieve status. |
| message | string | Confirmation message. Always "Email sent." |
json{ "id": "3c9b0f4e-1a2b-4c3d-8e5f-6a7b8c9d0e1f", "message": "Email sent." }✦ Tip
The returnedid is a UUID. Save it to your database to track delivery status per-email without polling. Use GET /v1/emails/:id to retrieve status on demand.Get email status
/v1/emails/:idRetrieve the delivery status of a previously sent email by its ID. The ID is the UUID returned by the Send email endpoint.
ℹ Note
You can only retrieve emails sent by your own organization. Requests for another organization's email IDs return 404.Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | string (UUID) | ✓ | The email ID returned by POST /v1/emails. |
Example request
bashcurl https://api.email.utobo.com/v1/emails/3c9b0f4e-1a2b-4c3d-8e5f-6a7b8c9d0e1f \
-H "Authorization: Bearer $UTOBO_EMAIL_API_KEY"Success response — HTTP 200
| Field | Type | Description |
|---|---|---|
| id | string | The email ID. |
| from | string | Sender address as passed in the request. |
| to | string[] | List of recipient addresses. |
| subject | string | Email subject. |
| status | string | "sent" if successfully delivered to SES, "failed" if an error occurred. |
| ses_message_id | string|null | AWS SES internal message ID. Useful for support tickets. May be null on failure. |
| created_at | string | ISO 8601 timestamp of when the email was sent. |
json{
"id": "3c9b0f4e-1a2b-4c3d-8e5f-6a7b8c9d0e1f",
"from": "Acme Support <support@acme.com>",
"to": ["alice@example.com"],
"subject": "Your order has shipped",
"status": "sent",
"ses_message_id": "0100019...@us-east-1.amazonses.com",
"created_at": "2026-05-29T10:30:00.000Z"
}Rate limits
Rate limits are applied per 1-minute sliding window. All limits are enforced server-side and cannot be increased without contacting support.
| Scope | Limit | Notes |
|---|---|---|
| Per API key | 100 req/min | Applies to each individual key |
| Per organization | 500 req/min | Shared across all keys in the org |
| Per IP (no auth) | 10 req/min | Prevents brute-force key enumeration |
Rate limit response
When a limit is exceeded, the API returns HTTP 429 with retry guidance:
jsonHTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Resource: key
{ "error": "API key rate limit exceeded.", "retry_after": 60 }Best practices
Error codes
Every error carries a human-readable error string. The transactional send API also returns a stable name (and a matching message) so you can branch in code without parsing prose — always switch on name:
json{
"error": "You don't have the 'emails:send' scope.",
"name": "insufficient_scope",
"message": "You don't have the 'emails:send' scope."
}Machine-readable names (send API)
| name | Status | Meaning |
|---|---|---|
| validation_error | 400 | A required field is missing or malformed (from, to, subject, html/text). |
| unauthorized | 401 | API key missing, invalid, expired or revoked. |
| insufficient_scope | 403 | The key is valid but lacks the scope this call needs. |
| quota_exceeded | 403 | Monthly send allowance used up — upgrade your plan. |
| sending_paused | 403 | Sending is paused or the account is under review. |
| domain_not_verified | 422 | The 'from' domain isn't verified. Verify it in Account → Domains. |
| all_recipients_suppressed | 422 | Every recipient is on your suppression list. |
| rate_limit_exceeded | 429 | Too many requests — see the Retry-After / X-RateLimit headers. |
| idempotency_conflict | 409 | A different payload reused an Idempotency-Key. |
| internal_error | 500 | Transient server error — retry after a few seconds. |
ℹ Note
The dashboard-only management endpoints (domains, API keys, sequences, automations) return just{ "error": "message" } with the HTTP status — they don't yet carry a name. The send, contacts, campaigns, webhooks and segments endpoints are the API-key surface.HTTP status reference
| Code | Status | Common cause |
|---|---|---|
| 400 | Bad Request | Missing required field (from, to, subject, html/text), invalid JSON, or malformed email address. |
| 401 | Unauthorized | API key is missing, invalid, expired, or has been revoked. |
| 403 | Forbidden | API key does not have the required permission (e.g. using a 'send-only' key for a manage operation). |
| 404 | Not Found | Email ID not found, or belongs to a different organization. |
| 405 | Method Not Allowed | Wrong HTTP method used (e.g. GET instead of POST for send). |
| 422 | Unprocessable Entity | The 'from' domain has not been verified. Verify it in Account → Domains. |
| 429 | Too Many Requests | Rate limit exceeded. Check the Retry-After header. |
| 500 | Internal Server Error | Something went wrong on our end. Retrying after 5–10 seconds usually resolves transient issues. |
Error handling example (Node.js SDK)
javascriptconst { data, error } = await client.emails.send({ ... });
if (error) {
switch (error.statusCode) {
case 401:
console.error('Invalid API key — check your UTOBO_EMAIL_API_KEY env var');
break;
case 422:
console.error('Domain not verified — go to Account → Domains');
break;
case 429:
console.error('Rate limited — retry after 60 seconds');
break;
default:
console.error('Send failed:', error.message);
}
return;
}
console.log('Sent:', data.id);SMTP relay
Prefer SMTP, or using a framework's built-in mailer? Point it at utobo's relay — the same API key, the same verified domains, and the same deliverability as the REST API, with no code changes beyond your mailer config.
Connection
| Host | smtp.mail.utobo.com |
| Port | 587 (STARTTLS, recommended) — 465 (SSL/TLS) also supported |
| Username | apikey ← the literal word, not your key |
| Password | your API key (ut_live_…) |
| Encryption | STARTTLS on 587, or SSL/TLS on 465 — required |
⚠ Warning
TheFrom address must be on a verified sending domain. A message may carry exactly one From and at most 50 recipients (to + cc + bcc); suppressed recipients are dropped automatically.Nodemailer
javascriptimport nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: "smtp.mail.utobo.com",
port: 587,
secure: false, // STARTTLS is negotiated on 587
auth: { user: "apikey", pass: process.env.UTOBO_EMAIL_API_KEY },
});
await transport.sendMail({
from: "Acme <noreply@acme.com>",
to: "user@example.com",
subject: "Welcome!",
html: "<p>Thanks for joining.</p>",
});Rails — ActionMailer
ruby# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.mail.utobo.com",
port: 587,
user_name: "apikey",
password: ENV["UTOBO_EMAIL_API_KEY"],
authentication: :plain,
enable_starttls_auto: true,
}Django
python# settings.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.mail.utobo.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "apikey"
EMAIL_HOST_PASSWORD = os.environ["UTOBO_EMAIL_API_KEY"]PHPMailer
php$mail->isSMTP();
$mail->Host = "smtp.mail.utobo.com";
$mail->Port = 587;
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = "apikey";
$mail->Password = getenv("UTOBO_EMAIL_API_KEY");Webhooks
Get a signed HTTP POST the moment something happens — a delivery, a bounce, a complaint, a new or unsubscribed contact. Add an endpoint in Account → Webhooks, or over the API with the manage-webhooks endpoint (a key holding the webhooks:manage scope). Up to 25 endpoints per workspace.
Event types
Subscribe to any subset, or * for all of them:
textcontact.created contact.updated contact.deleted
contact.added_to_list contact.removed_from_list
contact.unsubscribed contact.suppressed
email.sent email.delivered email.opened
email.clicked email.bounced email.complained
campaign.completed sequence.enrolledPayload
Every delivery is a JSON POST with this envelope:
json{
"id": "b1e7...", // unique event id
"type": "email.bounced", // the event type
"created_at": "2026-10-05T19:30:00Z", // when it happened (ISO 8601)
"data": { // event-specific payload
"email": "user@example.com",
"campaign_id": "c3a9...",
"subject": "Your order shipped"
}
}ℹ Note
Each POST carriesx-utobo-event (type), x-utobo-delivery (a unique delivery id), x-utobo-timestamp and x-utobo-signature. Respond 2xx within 10 seconds to acknowledge it.Verify signatures
When you add an endpoint we show a signing secret once (whsec_…) — store it, it isn't shown again. Every delivery is signed so you can prove it came from utobo. Recompute an HMAC-SHA256 over timestamp + "." + rawBody with your secret and compare in constant time; reject deliveries whose timestamp is more than 5 minutes old.
javascriptimport crypto from "crypto";
// Use the RAW request body bytes — not a re-serialized object.
function verifyUtoboWebhook(rawBody, headers, secret) {
const signature = headers["x-utobo-signature"]; // "sha256=<hex>"
const timestamp = headers["x-utobo-timestamp"]; // ms epoch
// Replay protection: reject deliveries older than 5 minutes.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}Retries & auto-disable
A delivery succeeds on any 2xx (a 3xx counts as a failure). Failed deliveries retry up to 6 times with backoff 1m → 5m → 30m → 2h → 6h → 24h. After 50 consecutive failures the endpoint is auto-disabled — a single success resets the counter; re-enable it from the dashboard.
Node.js SDK
The official utobo-email npm package is the recommended way to interact with the API. It provides full TypeScript types, the { data, error } response pattern (no try/catch needed), and automatic react-email support.
Installation
bashnpm install utobo-email # or yarn add utobo-email # or pnpm add utobo-emailInitialization
typescriptimport { UtoboEmail } from 'utobo-email';
// Option 1: Pass key directly
const client = new UtoboEmail('ut_live_your_api_key');
// Option 2: Use environment variable (recommended)
// Set UTOBO_EMAIL_API_KEY in your .env file
const client = new UtoboEmail(); // reads process.env.UTOBO_EMAIL_API_KEY
// Option 3: Custom base URL (advanced)
const client = new UtoboEmail(process.env.UTOBO_EMAIL_API_KEY, {
baseUrl: 'https://api.email.utobo.com',
});Send an email
typescriptconst { data, error } = await client.emails.send({
from: 'Acme <noreply@acme.com>',
to: 'user@example.com', // or ['a@b.com', 'c@d.com']
subject: 'Welcome to Acme!',
html: '<p>Thanks for signing up.</p>',
// Optional:
text: 'Thanks for signing up.', // Plain text fallback
cc: 'team@acme.com',
bcc: 'archive@acme.com',
reply_to: 'support@acme.com',
headers: { 'X-User-ID': '12345' },
});
if (error) console.error(error.message);
else console.log('Email ID:', data.id);Retrieve email status
typescriptconst { data, error } = await client.emails.get('3c9b0f4e-...');
if (!error) {
console.log(data.status); // "sent" | "failed"
console.log(data.created_at); // ISO timestamp
}With react-email
Install @react-email/render as an optional peer dependency, then pass a React component directly:
bashnpm install @react-email/render react react-domtypescriptimport { WelcomeEmail } from './emails/WelcomeEmail';
const { data, error } = await client.emails.send({
from: 'Acme <noreply@acme.com>',
to: 'user@example.com',
subject: 'Welcome!',
react: <WelcomeEmail name="Alice" plan="Starter" />,
// html is auto-generated from the React component
});TypeScript types
The SDK ships with full TypeScript declarations. All request and response shapes are exported from the package root:
typescriptimport type {
CreateEmailOptions,
CreateEmailResponseSuccess,
GetEmailResponseSuccess,
ErrorResponse,
Response,
} from 'utobo-email';✦ Tip
TheResponse<T> type is a discriminated union: either { data: T, error: null } or { data: null, error: ErrorResponse }. This means TypeScript enforces that you check for errors before accessing data.cURL
Use cURL for quick tests, shell scripts, or CI pipelines.
Send email
bashcurl -X POST https://api.email.utobo.com/v1/emails \
-H "Authorization: Bearer $UTOBO_EMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.com>",
"to": ["user@example.com"],
"subject": "Hello from utobo Email!",
"html": "<p>Your message here.</p>"
}'Get email status
bashcurl https://api.email.utobo.com/v1/emails/3c9b0f4e-1a2b-... \
-H "Authorization: Bearer $UTOBO_EMAIL_API_KEY"Send with attachments
bash# Encode file as base64
B64=$(base64 -i invoice.pdf | tr -d '\n')
curl -X POST https://api.email.utobo.com/v1/emails \
-H "Authorization: Bearer $UTOBO_EMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"from\": \"Billing <billing@acme.com>\",
\"to\": [\"customer@example.com\"],
\"subject\": \"Your invoice\",
\"html\": \"<p>Please find your invoice attached.</p>\",
\"attachments\": [{
\"filename\": \"invoice.pdf\",
\"content\": \"$B64\",
\"contentType\": \"application/pdf\"
}]
}"Python
The official utobo-email package. Reads UTOBO_EMAIL_API_KEY from the environment if you construct the client with no argument.
Installation
bashpip install utobo-emailSend email
pythonfrom utobo_email import UtoboEmail
client = UtoboEmail() # or UtoboEmail("ut_live_...")
result = client.emails.send(
from_="Acme <noreply@acme.com>", # note: from_ (from is reserved)
to="user@example.com",
subject="Welcome!",
html="<p>Thanks for joining.</p>",
)
print(result["id"])Check delivery status
pythonstatus = client.emails.get("3c9b0f4e-...")
print(status["status"]) # "sent" | "failed"Ruby
The official utobo-email gem.
Installation
bashgem install utobo-emailSend email
rubyrequire "utobo_email"
client = UtoboEmail.new("ut_live_...")
result = client.emails.send(
from: "Acme <noreply@acme.com>",
to: "user@example.com",
subject: "Welcome!",
html: "<p>Thanks for joining.</p>"
)
puts result["id"]Check delivery status
rubystatus = client.emails.get("3c9b0f4e-...")
puts status["status"] # "sent" | "failed"Go
The official Go module.
Installation
bashgo get github.com/myutobo/utobo-email-goSend email
goimport utoboemail "github.com/myutobo/utobo-email-go"
client, err := utoboemail.New("ut_live_...")
if err != nil { log.Fatal(err) }
resp, err := client.Emails.Send(&utoboemail.SendEmailRequest{
From: "Acme <noreply@acme.com>",
To: []string{"user@example.com"},
Subject: "Welcome!",
HTML: "<p>Thanks for joining.</p>",
})
if err != nil { log.Fatal(err) }
fmt.Println("Sent:", resp.ID)Check delivery status
gostatus, err := client.Emails.Get("3c9b0f4e-...")
fmt.Println(status.Status) // "sent" | "failed"PHP
Works with native cURL functions or the Guzzle HTTP client.
With Guzzle (recommended)
bashcomposer require guzzlehttp/guzzlephp<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$response = $client->post('https://api.email.utobo.com/v1/emails', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('UTOBO_EMAIL_API_KEY'),
'Content-Type' => 'application/json',
],
'json' => [
'from' => 'Acme <noreply@acme.com>',
'to' => ['user@example.com'],
'subject' => 'Welcome!',
'html' => '<p>Thanks for joining.</p>',
],
]);
$data = json_decode($response->getBody(), true);
echo 'Email ID: ' . $data['id'];With native cURL
php<?php
$payload = json_encode([
'from' => 'Acme <noreply@acme.com>',
'to' => ['user@example.com'],
'subject' => 'Welcome!',
'html' => '<p>Thanks for joining.</p>',
]);
$ch = curl_init('https://api.email.utobo.com/v1/emails');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('UTOBO_EMAIL_API_KEY'),
'Content-Type: application/json',
],
]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
echo 'Email ID: ' . $data['id'];Verify your domain
Before you can send emails, you must verify that you own the sending domain. This protects your recipients and ensures your emails reach the inbox, not spam.
Go to Account → Domains
In your utobo Email dashboard, navigate to Account → Domains and click Add domain.
Enter your domain
Type your domain name (e.g. yourcompany.com). Do not include http:// or a subdomain.
Add DNS records
utobo Email will show you TXT and CNAME records to add in your DNS provider (Cloudflare, Route53, Namecheap, etc.). Add all records shown.
Wait for propagation
DNS changes usually propagate within 5–15 minutes. Some providers take up to 48 hours. Click Check DNS in the dashboard to verify.
Domain is verified
Once all records are confirmed, the domain status changes to Verified. You can now send from any address at that domain.
ℹ Note
You only need to verify the root domain (e.g. yourcompany.com) once. You can then send from any address at that domain: noreply@, support@, hello@, etc.Common DNS providers
Cloudflare
DNS → Add record
AWS Route53
Hosted zones → Record sets
Namecheap
Domain list → Advanced DNS
GoDaddy
DNS Management
Google Domains
DNS → Manage custom records
Porkbun
DNS Records
API key best practices
Use one key per environment
Create separate keys for Development, Staging, and Production. This way you can rotate a compromised key without affecting other environments.
Store keys in environment variables
Never hardcode your API key in source code. Use environment variables (.env for local, platform secrets for production).
UTOBO_EMAIL_API_KEY=ut_live_your_key_hereUse least-privilege permissions
Choose 'Send only' permission for keys used in production apps. Only use 'Full access' for admin scripts that need to manage contacts or domains.
Rotate keys periodically
Create a new key, update your app's environment, verify it works, then revoke the old key. This is good security hygiene.
Revoke immediately if compromised
If a key leaks (e.g. accidentally committed to git), revoke it immediately in Account → API Keys. All requests using that key will stop working instantly.
Never use keys in browser code
API keys must only be used server-side. If you need to send email from a client-side app, create a server endpoint that calls the utobo Email API, and call that endpoint from your client.
Handle bounces & delivery errors
Email bounces happen when an email cannot be delivered. There are two types:
Hard bounce
Permanent delivery failure. The email address doesn't exist or the domain is invalid. Remove these addresses immediately — continuing to send harms your deliverability.
Soft bounce
Temporary failure. The mailbox is full or the server is temporarily unavailable. You can retry after 24–48 hours. After 3–5 soft bounces, treat as a hard bounce.
Viewing bounces in the dashboard
Go to Delivery Issues in your dashboard sidebar. All bounces and complaints are logged there with the email address, bounce type, and timestamp.
Best practices
React Email templates
The utobo Email Node.js SDK supports @react-email/render for building beautiful, cross-client email templates in JSX instead of raw HTML.
Setup
bashnpm install utobo-email @react-email/render react react-domCreate a template component
typescript// emails/WelcomeEmail.tsx
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f4f4f4' }}>
<Container style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
<Text style={{ fontSize: '24px', fontWeight: 'bold' }}>
Welcome, {name}!
</Text>
<Text style={{ color: '#555' }}>
Your account is ready. Click below to sign in.
</Text>
<Button
href={loginUrl}
style={{ background: '#4f46e5', color: '#fff', padding: '12px 24px', borderRadius: '6px' }}
>
Sign in to your account
</Button>
</Container>
</Body>
</Html>
);
}Send with the template
typescriptimport { UtoboEmail } from 'utobo-email';
import { WelcomeEmail } from './emails/WelcomeEmail';
const client = new UtoboEmail(process.env.UTOBO_EMAIL_API_KEY);
const { data, error } = await client.emails.send({
from: 'Acme <noreply@acme.com>',
to: 'alice@example.com',
subject: 'Welcome to Acme!',
// The SDK renders the React component to HTML automatically
react: <WelcomeEmail name="Alice" loginUrl="https://acme.com/login" />,
});✦ Tip
React Email components work in Next.js, Remix, Express, and any Node.js runtime. They also render correctly in all major email clients including Outlook, Gmail, and Apple Mail.Ready to start sending?
Create your account and start sending in minutes. Plans from $9/mo.