Step 1: Create Domain for a Tenant
When a customer wants to send email from their domain, create it scoped to their tenant:- Python
- Node.js
- Ruby
- cURL
Copy
from ark import Ark
client = Ark()
def onboard_customer_domain(tenant_id: str, domain_name: str):
"""Create a sending domain for a customer tenant."""
domain = client.domains.create(
name=domain_name,
tenant_id=tenant_id
)
return {
"domain_id": domain.data.id,
"dns_records": domain.data.dns_records,
"status": domain.data.verification_status
}
# Usage
result = onboard_customer_domain("tenant_abc123", "mail.customerdomain.com")
print(f"Domain created: {result['domain_id']}")
print(f"DNS records to configure: {len(result['dns_records'])}")
Copy
import Ark from 'ark';
const client = new Ark();
async function onboardCustomerDomain(tenantId: string, domainName: string) {
const domain = await client.domains.create({
name: domainName,
tenantId,
});
return {
domainId: domain.data.id,
dnsRecords: domain.data.dnsRecords,
status: domain.data.verificationStatus,
};
}
// Usage
const result = await onboardCustomerDomain('tenant_abc123', 'mail.customerdomain.com');
console.log(`Domain created: ${result.domainId}`);
console.log(`DNS records to configure: ${result.dnsRecords.length}`);
Copy
require "ark_email"
client = ArkEmail::Client.new
def onboard_customer_domain(tenant_id, domain_name)
domain = client.domains.create(
name: domain_name,
tenant_id: tenant_id
)
{
domain_id: domain.data.id,
dns_records: domain.data.dns_records,
status: domain.data.verification_status
}
end
# Usage
result = onboard_customer_domain("tenant_abc123", "mail.customerdomain.com")
puts "Domain created: #{result[:domain_id]}"
Copy
curl -X POST https://api.arkhq.io/v1/domains \
-H "Authorization: Bearer $ARK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "mail.customerdomain.com",
"tenant_id": "tenant_abc123"
}'
Step 2: Display DNS Records to Your Customer
The API returns the DNS records your customer needs to configure. Present these in your UI:- Python
- Node.js
Copy
def format_dns_instructions(domain_data):
"""Format DNS records for display in your customer UI."""
records = domain_data["dns_records"]
instructions = []
for record in records:
instructions.append({
"type": record["type"], # TXT, CNAME, MX
"hostname": record["hostname"], # e.g., "ark._domainkey.customerdomain.com"
"value": record["value"], # The record value to set
"purpose": record["purpose"], # "spf", "dkim", "return_path"
"verified": record["verified"] # True/False
})
return instructions
# Example output for your customer UI:
# ┌──────────┬───────────────────────────────────┬──────────────────┐
# │ Type │ Hostname │ Purpose │
# ├──────────┼───────────────────────────────────┼──────────────────┤
# │ TXT │ customerdomain.com │ SPF │
# │ TXT │ ark._domainkey.customerdomain.com │ DKIM │
# │ CNAME │ rp.customerdomain.com │ Return Path │
# └──────────┴───────────────────────────────────┴──────────────────┘
Copy
interface DnsInstruction {
type: string;
hostname: string;
value: string;
purpose: string;
verified: boolean;
}
function formatDnsInstructions(domainData: any): DnsInstruction[] {
return domainData.dnsRecords.map((record: any) => ({
type: record.type, // TXT, CNAME, MX
hostname: record.hostname, // e.g., "ark._domainkey.customerdomain.com"
value: record.value, // The record value to set
purpose: record.purpose, // "spf", "dkim", "return_path"
verified: record.verified, // true/false
}));
}
UX Best Practice: Include a “Copy” button next to each DNS value. DNS records are long and error-prone to type manually.
DNS Records Your Customers Need
| Record | Type | Purpose |
|---|---|---|
| TXT | Authorizes Ark to send on their behalf | |
| TXT | Cryptographically signs outgoing emails | |
| Return Path | CNAME | Routes bounce notifications back to Ark |
Step 3: Verify Domain with Polling
After your customer adds their DNS records, trigger verification. DNS propagation can take minutes to hours, so implement polling:- Python
- Node.js
- Ruby
Copy
import time
import ark
def verify_domain_with_polling(domain_id: str, max_attempts: int = 10):
"""
Poll domain verification with exponential backoff.
Returns verification result or raises after max attempts.
"""
for attempt in range(max_attempts):
result = client.domains.verify(domain_id)
status = result.data.verification_status
if status == "verified":
return {"status": "verified", "message": "Domain is ready to send!"}
# Check which records are still missing
pending_records = [
r for r in result.data.dns_records
if not r.verified
]
if attempt < max_attempts - 1:
# Exponential backoff: 10s, 20s, 40s, 80s...
wait_time = min(10 * (2 ** attempt), 300)
time.sleep(wait_time)
return {
"status": "pending",
"message": "DNS records not yet propagated",
"pending_records": [
{"type": r.type, "hostname": r.hostname, "purpose": r.purpose}
for r in pending_records
]
}
# Usage
result = verify_domain_with_polling("dom_xyz789")
if result["status"] == "verified":
print("✅ Domain verified! Customer can start sending.")
else:
print(f"⏳ Still waiting on: {result['pending_records']}")
Copy
async function verifyDomainWithPolling(
domainId: string,
maxAttempts = 10
): Promise<{ status: string; message: string; pendingRecords?: any[] }> {
let pendingRecords: any[] = [];
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await client.domains.verify(domainId);
const status = result.data.verificationStatus;
if (status === 'verified') {
return { status: 'verified', message: 'Domain is ready to send!' };
}
// Check which records are still missing
pendingRecords = result.data.dnsRecords.filter(
(r: any) => !r.verified
);
if (attempt < maxAttempts - 1) {
// Exponential backoff: 10s, 20s, 40s, 80s...
const waitTime = Math.min(10_000 * 2 ** attempt, 300_000);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
return {
status: 'pending',
message: 'DNS records not yet propagated',
pendingRecords: pendingRecords.map((r: any) => ({
type: r.type,
hostname: r.hostname,
purpose: r.purpose,
})),
};
}
// Usage
const result = await verifyDomainWithPolling('dom_xyz789');
if (result.status === 'verified') {
console.log('✅ Domain verified!');
} else {
console.log('⏳ Pending records:', result.pendingRecords);
}
Copy
def verify_domain_with_polling(domain_id, max_attempts: 10)
pending_records = []
max_attempts.times do |attempt|
result = client.domains.verify(domain_id)
status = result.data.verification_status
if status == "verified"
return { status: "verified", message: "Domain is ready to send!" }
end
pending_records = result.data.dns_records.select { |r| !r.verified }
if attempt < max_attempts - 1
wait_time = [10 * (2 ** attempt), 300].min
sleep(wait_time)
end
end
{
status: "pending",
message: "DNS records not yet propagated",
pending_records: pending_records.map { |r|
{ type: r.type, hostname: r.hostname, purpose: r.purpose }
}
}
end
Don’t poll too aggressively. DNS propagation typically takes 5–60 minutes. Start polling at 10-second intervals with exponential backoff up to 5 minutes between checks.
Step 4: Handle Verification Errors
Build helpful error messages for common DNS issues:- Python
- Node.js
Copy
def get_verification_help(pending_records):
"""Generate helpful guidance for pending DNS records."""
help_messages = []
for record in pending_records:
purpose = record.get("purpose", "")
if purpose == "spf":
help_messages.append({
"record": "SPF",
"issue": "SPF record not found or incorrect",
"fix": (
f"Add a TXT record to {record['hostname']} with value: "
f"v=spf1 include:spf.arkhq.io ~all\n"
"If you already have an SPF record, add 'include:spf.arkhq.io' "
"to your existing record instead of creating a new one."
)
})
elif purpose == "dkim":
help_messages.append({
"record": "DKIM",
"issue": "DKIM record not found",
"fix": (
f"Add a TXT record to {record['hostname']} with the value "
"shown in your domain setup page. DKIM values are long — "
"make sure you copy the complete value."
)
})
elif purpose == "return_path":
help_messages.append({
"record": "Return Path",
"issue": "Return path CNAME not found",
"fix": (
f"Add a CNAME record for {record['hostname']} pointing to "
"rp.arkhq.io"
)
})
return help_messages
Copy
function getVerificationHelp(pendingRecords: any[]) {
return pendingRecords.map(record => {
switch (record.purpose) {
case 'spf':
return {
record: 'SPF',
issue: 'SPF record not found or incorrect',
fix: `Add a TXT record to ${record.hostname} with value: ` +
`v=spf1 include:spf.arkhq.io ~all. ` +
`If you already have an SPF record, add 'include:spf.arkhq.io' ` +
`to your existing record.`,
};
case 'dkim':
return {
record: 'DKIM',
issue: 'DKIM record not found',
fix: `Add a TXT record to ${record.hostname} with the value ` +
`shown in your domain setup page. Copy the complete value.`,
};
case 'return_path':
return {
record: 'Return Path',
issue: 'Return path CNAME not found',
fix: `Add a CNAME record for ${record.hostname} pointing to rp.arkhq.io`,
};
default:
return {
record: record.purpose,
issue: 'Record not verified',
fix: `Check the DNS configuration for ${record.hostname}`,
};
}
});
}
Complete Onboarding Flow
Here’s a complete implementation that ties all steps together:- Python
- Node.js
Copy
from ark import Ark
import ark
import time
client = Ark()
class DomainOnboardingService:
"""Manages domain onboarding for SaaS customer tenants."""
def start_onboarding(self, tenant_id: str, domain_name: str):
"""Step 1: Create domain and return DNS instructions."""
try:
domain = client.domains.create(
name=domain_name,
tenant_id=tenant_id
)
return {
"success": True,
"domain_id": domain.data.id,
"dns_records": [
{
"type": r.type,
"hostname": r.hostname,
"value": r.value,
"purpose": r.purpose
}
for r in domain.data.dns_records
]
}
except ark.BadRequestError as e:
return {"success": False, "error": str(e.message)}
def check_verification(self, domain_id: str):
"""Step 2: Check verification status."""
result = client.domains.verify(domain_id)
records = result.data.dns_records
verified = [r for r in records if r.verified]
pending = [r for r in records if not r.verified]
return {
"status": result.data.verification_status,
"verified_count": len(verified),
"total_count": len(records),
"all_verified": len(pending) == 0,
"pending": [
{"purpose": r.purpose, "hostname": r.hostname}
for r in pending
],
"help": get_verification_help(
[{"purpose": r.purpose, "hostname": r.hostname} for r in pending]
) if pending else []
}
def send_test_email(self, tenant_id: str, domain_name: str, to: str):
"""Step 3: Send a test email from the newly verified domain."""
try:
email = client.emails.send(
from_=f"test@{domain_name}",
to=[to],
subject=f"Test email from {domain_name}",
html="<h1>Domain verified!</h1><p>Your domain is set up correctly.</p>",
text="Domain verified! Your domain is set up correctly.",
tenant_id=tenant_id
)
return {"success": True, "email_id": email.data.id}
except ark.APIError as e:
return {"success": False, "error": str(e.message)}
# Usage in your API endpoint
service = DomainOnboardingService()
# Customer enters domain
onboarding = service.start_onboarding("tenant_abc", "mail.customer.com")
# Customer clicks "Verify" after adding DNS records
verification = service.check_verification(onboarding["domain_id"])
if verification["all_verified"]:
# Send test email
test = service.send_test_email("tenant_abc", "mail.customer.com", "admin@customer.com")
print(f"✅ Onboarding complete! Test email: {test['email_id']}")
else:
print(f"⏳ {verification['verified_count']}/{verification['total_count']} records verified")
for item in verification["help"]:
print(f" → {item['record']}: {item['fix']}")
Copy
import Ark from 'ark';
const client = new Ark();
class DomainOnboardingService {
async startOnboarding(tenantId: string, domainName: string) {
try {
const domain = await client.domains.create({
name: domainName,
tenantId,
});
return {
success: true,
domainId: domain.data.id,
dnsRecords: domain.data.dnsRecords.map((r: any) => ({
type: r.type,
hostname: r.hostname,
value: r.value,
purpose: r.purpose,
})),
};
} catch (error) {
if (error instanceof Ark.BadRequestError) {
return { success: false, error: error.message };
}
throw error;
}
}
async checkVerification(domainId: string) {
const result = await client.domains.verify(domainId);
const records = result.data.dnsRecords;
const verified = records.filter((r: any) => r.verified);
const pending = records.filter((r: any) => !r.verified);
return {
status: result.data.verificationStatus,
verifiedCount: verified.length,
totalCount: records.length,
allVerified: pending.length === 0,
pending: pending.map((r: any) => ({
purpose: r.purpose,
hostname: r.hostname,
})),
help: pending.length > 0 ? getVerificationHelp(pending) : [],
};
}
async sendTestEmail(tenantId: string, domainName: string, to: string) {
try {
const email = await client.emails.send({
from: `test@${domainName}`,
to: [to],
subject: `Test email from ${domainName}`,
html: '<h1>Domain verified!</h1><p>Your domain is set up correctly.</p>',
text: 'Domain verified! Your domain is set up correctly.',
tenantId,
});
return { success: true, emailId: email.data.id };
} catch (error) {
if (error instanceof Ark.APIError) {
return { success: false, error: error.message };
}
throw error;
}
}
}
Background Verification (Recommended)
For the best UX, verify domains in the background instead of making your customer wait:- Python
- Node.js
Copy
import asyncio
from datetime import datetime, timedelta
async def background_domain_verification(domain_id: str, tenant_id: str):
"""
Run in a background job (Celery, etc.).
Polls verification every 2 minutes for up to 24 hours.
Notifies the customer when verification succeeds or fails.
"""
max_duration = timedelta(hours=24)
start_time = datetime.now()
poll_interval = 120 # 2 minutes
while datetime.now() - start_time < max_duration:
result = client.domains.verify(domain_id)
if result.data.verification_status == "verified":
# Notify customer (email, in-app notification, webhook)
await notify_customer(tenant_id, {
"type": "domain_verified",
"domain": result.data.name,
"message": "Your domain is verified and ready to send!"
})
return True
await asyncio.sleep(poll_interval)
# Verification timed out after 24 hours
await notify_customer(tenant_id, {
"type": "domain_verification_timeout",
"domain_id": domain_id,
"message": "Domain verification timed out. Please check your DNS records."
})
return False
Copy
async function backgroundDomainVerification(
domainId: string,
tenantId: string
): Promise<boolean> {
const maxDurationMs = 24 * 60 * 60 * 1000; // 24 hours
const startTime = Date.now();
const pollIntervalMs = 120_000; // 2 minutes
while (Date.now() - startTime < maxDurationMs) {
const result = await client.domains.verify(domainId);
if (result.data.verificationStatus === 'verified') {
await notifyCustomer(tenantId, {
type: 'domain_verified',
domain: result.data.name,
message: 'Your domain is verified and ready to send!',
});
return true;
}
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
await notifyCustomer(tenantId, {
type: 'domain_verification_timeout',
domainId,
message: 'Domain verification timed out. Please check DNS records.',
});
return false;
}
Best Practices
Use subdomains for sending
Use subdomains for sending
Recommend that customers use a subdomain like
mail.customer.com or notifications.customer.com rather than their root domain. This isolates sending reputation and avoids conflicts with existing DNS records.Show a progress indicator
Show a progress indicator
Display a clear status for each DNS record (verified/pending) so customers know exactly what’s left. Update the status in real-time using polling or WebSockets.
Provide copy-to-clipboard
Provide copy-to-clipboard
DNS record values (especially DKIM keys) are long and easy to mistype. Always provide a “Copy” button for each value.
Link to provider-specific guides
Link to provider-specific guides
Different DNS providers have different UIs. Link customers to the right guide based on their provider. See our domain setup hub for provider-specific instructions.
Handle existing SPF records
Handle existing SPF records
Many customers already have an SPF record. Your UI should detect this and instruct them to add
include:spf.arkhq.io to their existing record rather than creating a new one (multiple SPF records cause failures).Set up a DomainDNSError webhook
Set up a DomainDNSError webhook
Subscribe to
DomainDNSError events to proactively notify customers when their DNS configuration breaks after initial setup.