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, IManagedHostnameWriter/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": {
"IngressTarget": "customers.platform.example.com",
"TxtChallengePrefix": "_granit-challenge",
"VerificationBatchSize": 100,
"VerificationConcurrency": 10,
"DnsQueryTimeoutSeconds": 5,
"CertificateWebhookSecret": "vault:secret/data/hostnames#webhook-secret",
"CertificateWebhookSignatureHeader": "X-Webhook-Signature-256"
}
}

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);
}

IManagedHostnameWriter / IManagedHostnameReader

Section titled “IManagedHostnameWriter / IManagedHostnameReader”

CQRS split — inject the interface that matches your intent:

// Read-side: querying hostname metadata
public class HostnameQueryHandler(IManagedHostnameReader reader) { }
// Write-side: registering or updating hostnames
public class HostnameCommandHandler(IManagedHostnameWriter 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(IManagedHostnameWriter 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
IngressTargetnullPlatform CNAME target customers point their hostname at. When set, BeginVerification runs on creation so the response already carries the DNS records.
TxtChallengePrefix"_granit-challenge"DNS label prefix for the TXT ownership challenge — the full name is {prefix}.{host}.
VerificationBatchSize100Hostnames fetched by the poller per tick.
VerificationConcurrency10Parallel DNS verifications per tick, so one slow delegation chain cannot block the batch.
DnsQueryTimeoutSeconds5Per-query DNS timeout.
CertificateWebhookSecretnullShared secret for the HMAC-SHA-256 signature on POST /{id}/certificate-status. Skipped when null (local dev only) — source it from Vault.
CertificateWebhookSignatureHeader"X-Webhook-Signature-256"Header carrying the signature; override to match your edge provider.
CategoryKey typesPackage
ModuleGranitHostnamesModule, GranitHostnamesEntityFrameworkCoreModule, GranitHostnamesEndpointsModule, GranitHostnamesBackgroundJobsModule, GranitHostnamesNotificationsModule
AggregateManagedHostname, VerificationStatus, CertificateStatusGranit.Hostnames
InterfacesIHostnameResolver, IHostnameVerifier, IManagedHostnameWriter, IManagedHostnameReaderGranit.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