Skip to content

Hostnames — Custom Domain Management & DNS Verification

Every multi-tenant platform eventually needs to let customers use their own domain — app.theircustomer.com instead of yourplatform.com/theircustomer. Done by hand, this means writing DNS polling loops, managing retry state, wiring up SSL provider webhooks, and threading custom-domain logic through every service that cares about routing. Granit.Hostnames solves this once, generically.

It binds a custom domain to any opaque resource (OwnerType + OwnerId) and owns the full lifecycle: DNS TXT-record verification with exponential backoff, SSL certificate provisioning via webhook callback, and notification to consumers via ETOs — no reverse dependency on your application, no schema changes in your modules.

ChallengeDIYGranit.Hostnames
DNS ownership proofCustom polling loop, bespoke retry logicHostnameVerificationJob every 5 min, exponential backoff built-in
SSL provisioningDirect CDN/Let’s Encrypt integration per serviceWebhook endpoint (/certificate-status) updates CertificateStatus in one place
Resource bindingCustom join tables in each consumerGeneric OwnerType + OwnerId — zero schema changes in consumers
Cross-service eventsDirect calls or shared DTOs between modulesHostnameVerifiedEto, HostnameCertificateSecuredEto — consumers react without coupling
User communicationNotify users yourself2 notification types, 36 templates, 18 cultures, shipped

The canonical use case is a website builder or white-label portal where each customer gets their own branded domain:

  1. Customer registers app.theircustomer.com in your portal UI → POST /api/v1/hostnames
  2. Your UI shows the DNS TXT record to add — the customer updates their registrar
  3. HostnameVerificationJob polls every 5 min; on success it publishes HostnameVerifiedEto
  4. Your website module handles the ETO and activates the domain in its own routing table — no import of Granit.Hostnames required
  5. The SSL provider calls /certificate-statusHostnameCertificateSecuredEto
  6. If DNS isn’t configured after N attempts, HostnameVerificationFailedNotificationType notifies the customer automatically

The same pattern applies to billing portals, API gateways, and any multi-tenant surface that needs per-tenant vanity domains.

  • DirectoryGranit.Hostnames/ Domain model, ManagedHostname aggregate, DNS state machine, backoff
    • Granit.Hostnames.EntityFrameworkCore HostnamesDbContext, EfManagedHostnameStore, EfHostnameResolver
    • Granit.Hostnames.Endpoints CRUD, availability check, /verify-now, /certificate-status webhook
    • Granit.Hostnames.BackgroundJobs HostnameVerificationJob (every 5 min), batch service
    • Granit.Hostnames.Notifications hostname_verified + hostname_verification_failed (36 templates × 18 cultures)
PackageRoleDepends on
Granit.HostnamesManagedHostname aggregate, IHostnameResolver, IHostnameVerifier, IHostnameWriter/Reader, DNS FSM, exponential backoffGranit
Granit.Hostnames.EntityFrameworkCoreHostnamesDbContext, EfManagedHostnameStore, EfHostnameResolver, HostnamesOptionsGranit.Hostnames, Granit.Persistence
Granit.Hostnames.EndpointsMinimal API: CRUD, availability check, /verify-now, /certificate-status webhookGranit.Hostnames, Granit.Authorization
Granit.Hostnames.BackgroundJobsHostnameVerificationJob (5-min cron), HostnameVerificationBatchServiceGranit.Hostnames, Granit.BackgroundJobs
Granit.Hostnames.NotificationsHostnameVerifiedNotificationType, HostnameVerificationFailedNotificationTypeGranit.Hostnames, Granit.Notifications
graph TD
    HN[Granit.Hostnames] --> CO[Granit]
    EF[Granit.Hostnames.EntityFrameworkCore] --> HN
    EF --> P[Granit.Persistence]
    EP[Granit.Hostnames.Endpoints] --> HN
    EP --> A[Granit.Authorization]
    BJ[Granit.Hostnames.BackgroundJobs] --> HN
    BJ --> BGJ[Granit.BackgroundJobs]
    NT[Granit.Hostnames.Notifications] --> HN
    NT --> N[Granit.Notifications]

ManagedHostname drives two independent state machines: DNS verification and certificate provisioning. Both advance independently — a hostname can be DNS-verified while certificate provisioning is still in progress.

stateDiagram-v2
    [*] --> Pending : Register
    Pending --> Verifying : BeginVerification
    Verifying --> Active : DNS check passes
    Verifying --> Error : Check fails (max attempts)
    Error --> Verifying : Retry (exponential backoff)
    Active --> Verifying : ReVerify (ownership refresh)
StateDescription
PendingHostname registered; TXT record not yet checked
VerifyingBackground job polling DNS; backoff counter incrementing
ActiveTXT record found and validated; hostname is live
ErrorMax verification attempts reached; manual retry required

Retries use exponential backoff — delay doubles on each failed attempt up to a configurable ceiling (HostnamesOptions.MaxBackoffMinutes).

stateDiagram-v2
    [*] --> Unprovisioned
    Unprovisioned --> Provisioning : DNS Active
    Provisioning --> Secured : /certificate-status webhook (success)
    Provisioning --> Error : /certificate-status webhook (failure)
    Error --> Provisioning : Re-provision
StatusDescription
UnprovisionedDNS not yet active; SSL provider not contacted
ProvisioningSSL provider notified; awaiting certificate issuance
SecuredCertificate issued and installed
ErrorProvisioning failed; webhook reported an error

The /certificate-status webhook is a host-level endpoint — the SSL provider calls it to report provisioning outcomes. It requires the Hostnames.Certificates.Report permission, scoped to host-level callers.

[DependsOn(
typeof(GranitHostnamesEntityFrameworkCoreModule),
typeof(GranitHostnamesEndpointsModule),
typeof(GranitHostnamesBackgroundJobsModule),
typeof(GranitHostnamesNotificationsModule))]
public class AppModule : GranitModule { }
builder.AddGranitHostnames();
builder.AddGranitHostnamesEntityFrameworkCore(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Hostnames")));
{
"Hostnames": {
"VerificationPollInterval": "00:05:00",
"MaxVerificationAttempts": 10,
"BackoffBaseMinutes": 1,
"MaxBackoffMinutes": 60
}
}

Resolves a ManagedHostname by its hostname string or by owner identity:

public interface IHostnameResolver
{
Task<ManagedHostname?> FindByHostnameAsync(
string hostname,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<ManagedHostname>> FindByOwnerAsync(
string ownerType,
Guid ownerId,
CancellationToken cancellationToken = default);
}

CQRS split — inject the interface that matches your intent:

// Read-side: querying hostname metadata
public class HostnameQueryHandler(IHostnameReader reader) { }
// Write-side: registering or updating hostnames
public class HostnameCommandHandler(IHostnameWriter writer) { }

The DNS verification abstraction. Inject it to trigger on-demand verification outside the background job:

public interface IHostnameVerifier
{
Task<VerificationResult> VerifyAsync(
ManagedHostname hostname,
CancellationToken cancellationToken = default);
}

VerificationResult carries the resolved DNS values and the failure reason when the check does not pass, allowing callers to surface actionable feedback to users.

public class RegisterHostnameHandler(IHostnameWriter writer)
{
public async Task HandleAsync(RegisterHostnameCommand cmd, CancellationToken ct)
{
var hostname = ManagedHostname.Create(
id: Guid.NewGuid(),
hostname: cmd.Hostname, // e.g. "app.customer.com"
ownerType: "CmsPortal",
ownerId: cmd.PortalId);
await writer.AddAsync(hostname, ct).ConfigureAwait(false);
}
}

The aggregate is created in Pending state. The background job picks it up within the next poll interval and begins DNS verification.

All routes live under the Hostnames OpenAPI tag.

MethodRoutePermissionDescription
GET/api/{version}/hostnamesHostnames.Hostnames.ReadList managed hostnames (filterable by ownerType, ownerId, verificationStatus)
POST/api/{version}/hostnamesHostnames.Hostnames.ManageRegister a new custom hostname
GET/api/{version}/hostnames/{id}Hostnames.Hostnames.ReadGet hostname details and current status
DELETE/api/{version}/hostnames/{id}Hostnames.Hostnames.ManageRemove a managed hostname
GET/api/{version}/hostnames/availability— (public)Check whether a hostname is already registered
POST/api/{version}/hostnames/{id}/verify-nowHostnames.Hostnames.ManageTrigger an immediate DNS check (bypasses backoff)
POST/api/{version}/hostnames/certificate-statusHostnames.Certificates.ReportSSL provider webhook — updates CertificateStatus

GET /api/{version}/hostnames/availability?hostname=app.customer.com returns { "available": true }. This endpoint is unauthenticated so that registration UIs can validate before prompting the user to configure DNS.

The /certificate-status endpoint is designed to be called by the SSL provider (e.g., Cloudflare for SaaS, a Let’s Encrypt proxy). It accepts a JSON body describing the outcome and transitions CertificateStatus accordingly, then publishes HostnameCertificateSecuredEto or HostnameCertificateFailedEto.

JobCronDescription
HostnameVerificationJob*/5 * * * *Fetches all hostnames in Pending/Verifying/Error state and dispatches them to HostnameVerificationBatchService for DNS checking

HostnameVerificationBatchService processes the batch in parallel, applies exponential backoff between retries, and transitions each hostname to Active or Error depending on the DNS check outcome.

Granit.Hostnames.Notifications ships 2 notification types with 36 templates (2 × 18 cultures):

TypeNameSeverityOpt-out
HostnameVerifiedNotificationTypehostnames.hostname_verifiedInfoYes
HostnameVerificationFailedNotificationTypehostnames.hostname_verification_failedWarningNo

Default channel: InApp + Email. Each notification carries the hostname string and owner identity so the consumer can route the message to the right user.

ManagedHostname publishes ETOs on verification and certificate transitions. Consume them via Wolverine handlers to react in downstream services without coupling to Granit.Hostnames.

ETOTriggerKey fields
HostnameVerifiedEtoDNS check passes → ActiveHostnameId, Hostname, OwnerType, OwnerId, VerifiedAt
HostnameVerificationFailedEtoMax attempts reached → ErrorHostnameId, Hostname, OwnerType, OwnerId, FailureReason, AttemptCount
HostnameCertificateSecuredEtoWebhook reports success → SecuredHostnameId, Hostname, CertificateExpiresAt
HostnameCertificateFailedEtoWebhook reports failure → certificate ErrorHostnameId, Hostname, FailureReason
public class HostnameVerifiedHandler(ILogger<HostnameVerifiedHandler> logger)
{
public Task HandleAsync(HostnameVerifiedEto eto, CancellationToken ct)
{
logger.LogInformation(
"Hostname {Hostname} verified for {OwnerType}/{OwnerId}",
eto.Hostname, eto.OwnerType, eto.OwnerId);
return Task.CompletedTask;
}
}
PermissionScopeDescription
Hostnames.Hostnames.ReadStandardList and read managed hostnames
Hostnames.Hostnames.ManageStandardRegister, delete, and trigger verification
Hostnames.Certificates.ReportHost-levelAllowed for SSL provider webhook callers only

Hostnames.Certificates.Report is a host-level permission — it is not assignable to regular users. Grant it to the machine account or API key used by your SSL provider to call the /certificate-status webhook.

PropertyDefaultDescription
VerificationPollInterval00:05:00Cron cadence for HostnameVerificationJob
MaxVerificationAttempts10Attempts before transitioning to Error
BackoffBaseMinutes1Initial retry delay (doubles each attempt)
MaxBackoffMinutes60Ceiling for exponential backoff
CategoryKey typesPackage
ModuleGranitHostnamesModule, GranitHostnamesEntityFrameworkCoreModule, GranitHostnamesEndpointsModule, GranitHostnamesBackgroundJobsModule, GranitHostnamesNotificationsModule
AggregateManagedHostname, VerificationStatus, CertificateStatusGranit.Hostnames
InterfacesIHostnameResolver, IHostnameVerifier, IHostnameWriter, IHostnameReaderGranit.Hostnames
Value objectsVerificationResult, HostnamesOptionsGranit.Hostnames
ETOsHostnameVerifiedEto, HostnameVerificationFailedEto, HostnameCertificateSecuredEto, HostnameCertificateFailedEtoGranit.Hostnames
PersistenceHostnamesDbContext, EfManagedHostnameStore, EfHostnameResolverGranit.Hostnames.EntityFrameworkCore
BackgroundHostnameVerificationJob, HostnameVerificationBatchServiceGranit.Hostnames.BackgroundJobs
NotificationsHostnameVerifiedNotificationType, HostnameVerificationFailedNotificationTypeGranit.Hostnames.Notifications
ExtensionsAddGranitHostnames(), AddGranitHostnamesEntityFrameworkCore()
  • Background Jobs — recurring job infrastructure used by HostnameVerificationJob
  • Notifications — notification dispatch and template system
  • Event Bus — ETO publishing and Wolverine handler registration
  • Authorization — host-level permissions and RBAC