When building a white-label email platform, your customers need to configure their own sending domains. This guide shows how to build an automated onboarding flow that guides them through DNS setup and verification.Documentation Index
Fetch the complete documentation index at: https://arkhq.io/docs/llms.txt
Use this file to discover all available pages before exploring further.
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
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'])}")
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}`);
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]}"
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
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 │
# └──────────┴───────────────────────────────────┴──────────────────┘
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
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']}")
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);
}
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
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
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
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']}")
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
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
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.Next Steps
Architecture Overview
Understand the Platform → Tenant → Domain hierarchy
Domain Setup
DNS record reference and provider-specific guides
Tenant Management
Full CRUD operations for tenants
Sending Emails
Send tenant-scoped emails after domain verification
