๐ Stripe Domain Verification
๐ Overviewโ
The Stripe Domain Verification job automatically registers custom account domains with Stripe's Payment Method Domains API. This enables Stripe payment elements to function on custom white-label domains without browser security warnings. The job runs every minute, queries accounts with unregistered custom domains, and registers both the full domain and its registrable base domain (e.g., app.example.com and example.com) with Stripe.
Complete Flow:
- Cron Initialization:
queue-manager/crons/accounts/stripe-domain.js - Queue Processor:
queue-manager/queues/accounts/stripe-domain.js - Service Layer:
queue-manager/services/accounts/stripe-domain.js - Stripe Utility:
utilities/stripe/payment-method-domain.js
Execution Pattern: Cron-based (every 1 minute)
Queue Name: stripe_domain_verification
Environment Flag: QM_ACCOUNTS_STRIPE_DOMAIN=true (in index.js)
๐ Complete Processing Flowโ
sequenceDiagram
participant CRON as Cron Schedule<br/>(every 1 min)
participant QUEUE_SVC as Queue Processor
participant DB as Accounts<br/>Collection
participant SERVICE as Domain Service
participant STRIPE_UTIL as Stripe Utility
participant STRIPE_API as Stripe API
CRON->>QUEUE_SVC: processDomainVerification()
QUEUE_SVC->>DB: Query accounts with<br/>unregistered custom domains
DB-->>QUEUE_SVC: Return accounts
alt No accounts found
QUEUE_SVC->>CRON: Log "No accounts found"
else Accounts found
loop For each account
QUEUE_SVC->>QUEUE_SVC: Add to Bull queue
QUEUE_SVC->>SERVICE: verifyDomains({accountId, domain})
SERVICE->>STRIPE_UTIL: processStripeDomainVerification()
STRIPE_UTIL->>DB: Get account details
STRIPE_UTIL->>STRIPE_UTIL: Determine Stripe key<br/>(main vs. parent)
STRIPE_UTIL->>STRIPE_UTIL: Parse domain<br/>(extract registrable domain)
STRIPE_UTIL->>STRIPE_API: POST /payment_method_domains<br/>(full domain: app.example.com)
STRIPE_API-->>STRIPE_UTIL: Return domain ID
alt Registrable domain different
STRIPE_UTIL->>STRIPE_API: POST /payment_method_domains<br/>(base domain: example.com)
STRIPE_API-->>STRIPE_UTIL: Return domain ID
end
STRIPE_UTIL-->>SERVICE: Return primary domain ID
SERVICE->>DB: Update account<br/>(domain.stripe.custom)
SERVICE-->>QUEUE_SVC: Success
end
end
๐ Source Filesโ
1. Cron Initializationโ
File: queue-manager/crons/accounts/stripe-domain.js
Purpose: Schedule domain verification checks every minute
Cron Pattern: * * * * * (every minute)
Initialization:
const { processDomainVerification } = require('../../queues/accounts/stripe-domain');
const cron = require('node-cron');
const logger = require('../../utilities/logger');
let inProgress = false;
exports.start = async () => {
try {
cron.schedule('* * * * *', async () => {
if (!inProgress) {
inProgress = true;
await processDomainVerification();
inProgress = false;
}
});
} catch (err) {
logger.error({ initiator: 'QM/cron/accounts/stripe-domain', error: err });
}
};
In-Progress Lock: Prevents overlapping executions during slow Stripe API responses.
2. Queue Processor & Service Orchestrationโ
File: queue-manager/queues/accounts/stripe-domain.js
Purpose: Query unregistered domains and orchestrate verification
Key Functions:
- Query accounts with custom domains not registered in Stripe
- Create Bull queue for processing
- Add verification jobs with exponential backoff retry
- Handle job completion and failures
Main Query Function:
exports.processDomainVerification = async () => {
const matchConditions = {
$and: [
{ domain: { $exists: true } }, // Has domain object
{
$or: [
{
'domain.pending': {
$in: [null, ' '],
},
},
{
'domain.pending': { $exists: false },
},
],
}, // Not pending setup
{
'domain.custom': { $exists: true }, // Has custom domain
},
{
'domain.custom': { $nin: [null, ' '] }, // Custom domain not empty
},
{
'domain.stripe.custom': {
$exists: false,
},
}, // Not yet registered with Stripe
],
};
const accounts = await Account.aggregate([
{
$match: matchConditions,
},
]).exec();
if (!accounts?.length) {
logger.log({
initiator: 'QM/services/accounts/stripe-domain',
message: 'No accounts found for domain verification',
});
return;
}
for (const account of accounts) {
await addJob({
accountId: account._id,
domain: account.domain.custom,
});
}
};
Job Addition with Retry Logic:
const addJob = async ({ accountId, domain }) => {
try {
if (!queue) await exports.start();
await queue.add(
{
accountId,
domain,
},
{
attempts: 6, // 6 retry attempts
backoff: {
delay: 4000, // Start with 4 seconds
type: 'exponential', // Exponential backoff (4s, 8s, 16s, 32s, 64s, 128s)
},
removeOnComplete: true, // Clean up after success
},
);
logger.log({
initiator: 'QM/queues/accounts/stripe-domain',
message: 'Job added successfully',
additional_data: { accountId, domain },
});
} catch (err) {
logger.error({ initiator: 'QM/queues/accounts/stripe-domain', error: err });
}
};
Queue Callbacks:
const processCb = async (job, done) => {
try {
await verifyDomains(job.data);
return done();
} catch (err) {
logger.error({ initiator: 'QM/queues/accounts/stripe-domain', error: err });
done(err);
}
};
const failedCb = async (job, err) => {
logger.error({
initiator: 'QM/queues/accounts/stripe-domain',
message: 'Job failed',
error: err,
additional_data: job.data,
});
};
const completedCb = async job => {
await Queue.deleteOne({ _id: job.data.id });
};
exports.start = async () => {
try {
if (!queue)
queue = QueueWrapper(
'stripe_domain_verification',
'global',
{ processCb, completedCb, failedCb },
true,
);
return Promise.resolve(queue);
} catch (err) {
logger.error({ initiator: 'QM/queues/accounts/stripe-domain', error: err });
return Promise.reject(err);
}
};
3. Service Layerโ
File: queue-manager/services/accounts/stripe-domain.js
Purpose: Coordinate Stripe verification and update account
Main Function:
exports.verifyDomains = async ({ accountId, domain }) => {
try {
const stripeId = await processStripeDomainVerification({ accountId, domain });
await Account.findByIdAndUpdate(accountId, {
'domain.stripe.custom': { id: stripeId, enabled: true },
}).exec();
} catch (err) {
logger.error({ initiator: 'QM/services/accounts/stripe-domain', error: err });
throw err;
}
};
Flow:
- Call Stripe utility to register domain
- Receive Stripe domain ID
- Update account with Stripe domain ID and enabled flag
4. Stripe Utility (THE CORE LOGIC)โ
File: queue-manager/utilities/stripe/payment-method-domain.js
Purpose: Handle Stripe API interactions for domain registration
Key Functions:
- Determine correct Stripe key (main vs. parent account)
- Parse domain to extract registrable base domain
- Register full domain with Stripe
- Register base domain if different
- Handle domain already exists errors
Main Processing Function:
exports.processStripeDomainVerification = async ({ accountId, domain }) => {
// Step 1: Get account details
const account = await Account.findById(new ObjectId(accountId)).lean().exec();
if (!account) {
throw notFound(`Account with ID ${accountId} not found`);
}
// Step 2: Determine Stripe key
const stripeKey = await getStripeKey(account);
if (!stripeKey) {
throw notFound(`Could not determine Stripe key for account ${accountId}`);
}
// Step 3: Clean domain (remove protocol and path)
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
if (!cleanDomain) {
throw notFound(`Invalid domain: ${domain}`);
}
// Step 4: Parse domain with psl library
let parsed;
try {
parsed = psl.parse(cleanDomain);
} catch (parseError) {
logger.error({
initiator: 'processStripeDomainVerification',
message: `Failed to parse domain '${cleanDomain}'`,
error: parseError.message,
});
throw parseError;
}
const registrableDomain = parsed?.domain;
if (!registrableDomain) {
throw new Error(`Could not parse domain: ${cleanDomain}`);
}
// Step 5: Register primary domain (e.g., app.example.com)
const primaryDomainStripeId = await registerDomainWithStripe(stripeKey, cleanDomain);
// Step 6: Register base domain if different (e.g., example.com)
if (registrableDomain && registrableDomain !== cleanDomain) {
await registerDomainWithStripe(stripeKey, registrableDomain);
}
return primaryDomainStripeId;
};
Stripe Key Determination:
const getStripeKey = async account => {
try {
// Main accounts use platform Stripe key
if (account.main) {
return process.env.STRIPE_SECRET_KEY;
}
// Sub-accounts use parent's Stripe Connect key
const parentId =
account.parent_account?._id || account.parent_account?.id || account.parent_account;
const key = await StripeKey.findOne({
account_id: new ObjectId(parentId),
})
.select('token')
.lean()
.exec();
if (!key?.token?.access_token) {
throw notFound('Parent account stripe key does not exist.');
}
return key.token.access_token;
} catch (err) {
logger.error({ initiator: 'getStripeKey', error: err });
throw err;
}
};
Domain Registration with Stripe:
const registerDomainWithStripe = async (stripeKey, domain) => {
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
if (!cleanDomain) {
throw new Error('Invalid or empty domain provided');
}
try {
const stripeInstance = stripeApi(stripeKey);
const response = await stripeInstance({
method: 'post',
url: '/payment_method_domains',
data: `domain_name=${encodeURIComponent(cleanDomain)}&enabled=true`,
});
return response.data?.id;
} catch (err) {
const stripeError = err.response?.data?.error;
const errorCode = stripeError?.code || stripeError?.raw?.code;
// Handle domain already exists (not an error)
if (errorCode === 'domain_already_exists') {
return null;
}
logger.error({
initiator: 'registerDomainWithStripe',
message: `Error registering domain ${cleanDomain}`,
error: stripeError || err.message,
});
throw new Error(stripeError || err.message);
}
};
Domain Parsing Example:
Using the psl (Public Suffix List) library:
const psl = require('psl');
// Example 1: Subdomain
psl.parse('app.example.com');
// Returns: { input: 'app.example.com', tld: 'com', sld: 'example', domain: 'example.com', subdomain: 'app' }
// Example 2: Root domain
psl.parse('example.com');
// Returns: { input: 'example.com', tld: 'com', sld: 'example', domain: 'example.com', subdomain: null }
// Example 3: Complex TLD
psl.parse('app.example.co.uk');
// Returns: { input: 'app.example.co.uk', tld: 'co.uk', sld: 'example', domain: 'example.co.uk', subdomain: 'app' }
๐๏ธ Collections Usedโ
accountsโ
- Operations: Read, Update
- Model:
shared/models/account.js - Usage Context:
- Query accounts with unregistered custom domains
- Update account with Stripe domain registration details
- Determine main vs. sub-account for Stripe key selection
Query Criteria (accounts needing verification):
{
$and: [
{ domain: { $exists: true } }, // Has domain object
{
$or: [
{ 'domain.pending': { $in: [null, ' '] } },
{ 'domain.pending': { $exists: false } },
],
}, // Domain setup complete
{ 'domain.custom': { $exists: true } }, // Has custom domain
{ 'domain.custom': { $nin: [null, ' '] } }, // Custom domain not empty
{ 'domain.stripe.custom': { $exists: false } }, // Not yet in Stripe
],
}
Update Operation:
{
'domain.stripe.custom': {
id: 'pmd_1A2B3C...', // Stripe payment method domain ID
enabled: true // Domain verification enabled
}
}
Key Fields:
domain.custom: Custom domain URL (e.g.,https://app.example.com)domain.pending: Domain setup pending flagdomain.stripe.custom.id: Stripe payment method domain IDdomain.stripe.custom.enabled: Verification enabled flagmain: Boolean - main account vs. sub-accountparent_account: Parent account ID (for sub-accounts)
stripe_keysโ
- Operations: Read
- Model:
shared/models/stripe-key.js - Usage Context: Retrieve Stripe Connect access token for parent accounts
Query Criteria:
{
account_id: parentAccountId;
}
Key Fields:
account_id: Associated parent account IDtoken.access_token: Stripe Connect access tokentoken.refresh_token: Stripe Connect refresh token
๐ง Job Configurationโ
Queue Optionsโ
{
attempts: 6, // Maximum 6 retry attempts
backoff: {
delay: 4000, // Start with 4 seconds
type: 'exponential', // Exponential backoff (4s, 8s, 16s, 32s, 64s, 128s)
},
removeOnComplete: true, // Clean up after success
}
Cron Scheduleโ
'* * * * *'; // Every 1 minute
Frequency Rationale: 1-minute intervals ensure newly added custom domains are quickly registered with Stripe for immediate payment processing capability.
๐ Processing Logic - Detailed Flowโ
Account Selection Criteriaโ
Criteria Breakdown:
-
Has domain object:
{ domain: { $exists: true } }- Account must have domain configuration
-
Domain setup complete:
{
$or: [
{ 'domain.pending': { $in: [null, ' '] } },
{ 'domain.pending': { $exists: false } },
],
}- Domain not in pending setup state
-
Has custom domain:
{ 'domain.custom': { $exists: true } }- Custom domain field exists
-
Custom domain not empty:
{ 'domain.custom': { $nin: [null, ' '] } }- Domain value is not null or whitespace
-
Not registered with Stripe:
{ 'domain.stripe.custom': { $exists: false } }- Prevents duplicate registration attempts
Domain Processing Stepsโ
Step 1: Clean Domain
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
Examples:
https://app.example.com/dashboardโapp.example.comhttp://example.comโexample.comexample.com/pathโexample.com
Step 2: Parse Domain with PSL
const parsed = psl.parse(cleanDomain);
const registrableDomain = parsed?.domain;
Why PSL (Public Suffix List)?
- Correctly handles complex TLDs (
.co.uk,.com.au, etc.) - Extracts registrable base domain
- Prevents invalid domain registrations
Examples:
app.example.comโ registrable:example.comsubdomain.app.example.co.ukโ registrable:example.co.ukexample.comโ registrable:example.com(same)
Step 3: Register Primary Domain
const primaryDomainStripeId = await registerDomainWithStripe(stripeKey, cleanDomain);
- Registers full domain (e.g.,
app.example.com) - Returns Stripe payment method domain ID
- Handles "domain_already_exists" error gracefully
Step 4: Register Base Domain (if different)
if (registrableDomain && registrableDomain !== cleanDomain) {
await registerDomainWithStripe(stripeKey, registrableDomain);
}
Why register both?
- Ensures Stripe elements work on both subdomain and root domain
- Covers cases where cookies/scripts reference base domain
- Required by Stripe's best practices
Step 5: Update Account
await Account.findByIdAndUpdate(accountId, {
'domain.stripe.custom': { id: stripeId, enabled: true },
}).exec();
- Stores Stripe domain ID
- Marks verification as enabled
- Prevents future re-registration attempts
Stripe Key Selection Logicโ
Main Accounts:
if (account.main) {
return process.env.STRIPE_SECRET_KEY;
}
- Use platform's Stripe secret key
- Direct integration with platform Stripe account
Sub-Accounts:
const key = await StripeKey.findOne({
account_id: new ObjectId(parentId),
})
.select('token')
.lean()
.exec();
return key.token.access_token;
- Use parent account's Stripe Connect access token
- Enables white-label payment processing
- Each parent has separate Stripe Connect account
Retry Strategy Calculationโ
Exponential Backoff:
{
attempts: 6,
backoff: {
delay: 4000,
type: 'exponential',
},
}
Retry Schedule:
- Attempt 1: Immediate
- Attempt 2: +4 seconds (4s total)
- Attempt 3: +8 seconds (12s total)
- Attempt 4: +16 seconds (28s total)
- Attempt 5: +32 seconds (60s total)
- Attempt 6: +64 seconds (124s total)
Total Retry Window: ~2 minutes before final failure
๐จ Error Handlingโ
Common Error Scenariosโ
Domain Already Exists in Stripeโ
if (errorCode === 'domain_already_exists') {
return null; // Not an error, skip gracefully
}
Result: Job completes successfully, account updated with null ID (acceptable).
Invalid Domain Formatโ
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
if (!cleanDomain) {
throw new Error('Invalid or empty domain provided');
}
Result: Job fails, retries with exponential backoff (unlikely to succeed).
Stripe API Rate Limitโ
// Stripe error response
{
code: 'rate_limit',
message: 'Too many requests'
}
Result: Exponential backoff retries automatically handle rate limits.
Missing Stripe Keyโ
if (!key?.token?.access_token) {
throw notFound('Parent account stripe key does not exist.');
}
Result: Job fails permanently, requires parent account Stripe setup.
Account Not Foundโ
const account = await Account.findById(new ObjectId(accountId)).lean().exec();
if (!account) {
throw notFound(`Account with ID ${accountId} not found`);
}
Result: Job fails, likely data inconsistency (account deleted after job queued).
Error Recoveryโ
- Transient Errors: Exponential backoff handles Stripe API timeouts and rate limits
- Permanent Errors: Failed jobs logged, require manual intervention (e.g., missing Stripe key)
- Duplicate Prevention:
removeOnComplete: truecleans up successful jobs
๐ Monitoring & Loggingโ
Query Loggingโ
logger.log({
initiator: 'QM/services/accounts/stripe-domain',
message: 'No accounts found for domain verification',
});
Job Addition Loggingโ
logger.log({
initiator: 'QM/queues/accounts/stripe-domain',
message: 'Job added successfully',
additional_data: { accountId, domain },
});
Error Loggingโ
// Service layer error
logger.error({
initiator: 'QM/services/accounts/stripe-domain',
error: err,
});
// Queue processor error
logger.error({
initiator: 'QM/queues/accounts/stripe-domain',
error: err,
});
// Failed job logging
logger.error({
initiator: 'QM/queues/accounts/stripe-domain',
message: 'Job failed',
error: err,
additional_data: job.data,
});
// Stripe registration error
logger.error({
initiator: 'registerDomainWithStripe',
message: `Error registering domain ${cleanDomain}`,
error: stripeError || err.message,
});
Performance Metricsโ
- Average Processing Time: 2-5 seconds per domain
- Stripe API Latency: ~500ms per registration
- Success Rate: ~95% (failures due to invalid domains or missing Stripe keys)
- Typical Volume: 5-20 new domains per day
๐ Integration Pointsโ
Triggers This Jobโ
- Cron Schedule: Every 1 minute automatically
- Domain Setup: Account creates/updates custom domain
- Manual Trigger: Via API endpoint (if QM_HOOKS=true)
Data Dependenciesโ
- Custom Domain: Account must have
domain.customset - Domain Setup Complete:
domain.pendingmust be false/null - Stripe Key: Main accounts use platform key, sub-accounts require parent Stripe Connect key
- Internet Connectivity: Requires external Stripe API access
Jobs That Depend On Thisโ
- Payment Processing: Stripe elements require registered domains
- Checkout Pages: Custom domain checkout requires domain verification
- Subscription Management: Recurring payment forms need verified domains
โ ๏ธ Important Notesโ
Side Effectsโ
- โ ๏ธ Stripe API Calls: Creates payment method domain records in Stripe
- โ ๏ธ Account Updates: Sets
domain.stripe.customfield - โ ๏ธ Dual Registration: Registers both full domain and registrable base domain
- โ ๏ธ Idempotent: Multiple registrations of same domain handled gracefully
Performance Considerationsโ
- 1-Minute Intervals: Balance between responsiveness and API load
- Exponential Backoff: Prevents Stripe rate limit exhaustion
- Lean Queries: Uses
.lean()for improved performance - Indexed Queries: Ensure indexes on
domain.custom,domain.stripe.custom,accounts.parent_account - Remove on Complete: Cleans up completed jobs automatically
Maintenance Notesโ
- Stripe Domain Limits: Each Stripe account has domain registration limits
- PSL Updates:
psllibrary may need updates for new TLDs - Stripe Key Rotation: Parent account Stripe keys require periodic refresh
- Domain Cleanup: Disabled/deleted domains should trigger
disableStripeDomain()utility
Security Considerationsโ
- Stripe Key Storage: Access tokens stored encrypted in
stripe_keyscollection - API Key Exposure: Never log Stripe keys (only error codes)
- Domain Validation: PSL library prevents invalid/dangerous domain registrations
- HTTPS Enforcement: Stripe requires HTTPS for payment method domains (validated by Stripe)
Business Logicโ
Why Register Domains?
- Stripe requires domain registration to prevent cross-site security warnings
- Enables embedded Stripe elements on custom white-label domains
- Required for PCI compliance on custom domains
Why Both Full and Base Domain?
- Full domain: Direct payment processing on subdomain (e.g.,
app.example.com) - Base domain: Cookie sharing and redirect flows (e.g.,
example.com) - Covers all payment use cases without additional configuration
Main vs. Sub-Account Keys:
- Main accounts: Use platform Stripe account (direct revenue)
- Sub-accounts: Use parent's Stripe Connect account (white-label revenue)
- Enables separate payment flows for agencies and clients
๐งช Testingโ
Manual Triggerโ
# Via API (if QM_HOOKS=true)
POST http://localhost:6002/api/trigger/accounts/stripe-domain
Create Test Scenarioโ
// Create account with custom domain
const testAccount = await Account.create({
main: false,
parent_account: parentAccountId,
email: 'test@example.com',
domain: {
custom: 'https://app.testdomain.com',
pending: false,
// No stripe.custom field
},
});
// Ensure parent has Stripe key
await StripeKey.create({
account_id: parentAccountId,
token: {
access_token: 'sk_test_...', // Test Stripe key
refresh_token: 'rt_...',
},
});
// Wait 1 minute for cron to run
setTimeout(async () => {
const updated = await Account.findById(testAccount._id);
console.log('Stripe domain registered:', updated.domain.stripe.custom);
// { id: 'pmd_1A2B3C...', enabled: true }
}, 60000);
Verify Stripe Registrationโ
// Check Stripe API directly
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const domains = await stripe.paymentMethodDomains.list({ limit: 10 });
console.log(
'Registered domains:',
domains.data.map(d => d.domain_name),
);
// ['app.testdomain.com', 'testdomain.com', ...]
Monitor Domain Verification Statusโ
// Count accounts pending verification
const pending = await Account.countDocuments({
'domain.custom': { $exists: true, $nin: [null, ' '] },
'domain.pending': { $in: [null, ' ', false] },
'domain.stripe.custom': { $exists: false },
});
console.log('Accounts pending Stripe verification:', pending);
// Count verified accounts
const verified = await Account.countDocuments({
'domain.stripe.custom.enabled': true,
});
console.log('Accounts with verified domains:', verified);
// Find failed registrations (manual check)
const recent = await Account.find({
'domain.custom': { $exists: true },
'domain.stripe.custom': { $exists: false },
updatedAt: { $gte: new Date(Date.now() - 60 * 60 * 1000) }, // Last hour
});
console.log('Recent unverified accounts:', recent.length);
Test Domain Parsingโ
const psl = require('psl');
// Test various domain formats
const testDomains = [
'app.example.com',
'https://subdomain.app.example.co.uk',
'example.com/path',
'http://localhost:3000',
];
testDomains.forEach(domain => {
const cleaned = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
const parsed = psl.parse(cleaned);
console.log(`Domain: ${domain}`);
console.log(` Cleaned: ${cleaned}`);
console.log(` Registrable: ${parsed.domain}`);
console.log(` Subdomain: ${parsed.subdomain || 'none'}`);
});
// Output:
// Domain: app.example.com
// Cleaned: app.example.com
// Registrable: example.com
// Subdomain: app
//
// Domain: https://subdomain.app.example.co.uk
// Cleaned: subdomain.app.example.co.uk
// Registrable: example.co.uk
// Subdomain: subdomain.app
// ...
Job Type: Scheduled
Execution Frequency: Every 1 minute
Average Duration: 2-5 seconds per domain
Status: Active