Flows

Custom Domain Flow

Attaching a custom domain to a storefront.

This document explains how a store connects its own domain (e.g. mystore.com) to its Sendy storefront, proves ownership through DNS, and how the public storefront API then resolves the store by that verified domain. It covers the two required DNS records, the step-by-step setup sequence, all relevant endpoints, request/response shapes, error codes, and the infrastructure prerequisite for TLS.


Overview

By default a storefront is reachable through its Sendy slug. A store may additionally connect a custom domain so customers visit the shop under the store's own brand. Connecting a domain requires two DNS records:

  1. Routing record (CNAME / A) — points the domain at the Sendy server so traffic arrives at the API with Host: mystore.com.
  2. Verification record (TXT) — proves the store owns the domain. Only after this check passes does Sendy begin serving the storefront for that domain.
Store enters custom domain  (PUT /store/storefront)
        
        
Backend generates a TXT verification token + returns the CNAME target
        
        
Store adds 2 DNS records at their domain provider
   ├─ CNAME  mystore.com    api.sendyiq.com      (routing)
   └─ TXT    _sendy-verify  sendy-verify={token} (ownership)
        
        
Store clicks "Verify Domain"  (POST /store/storefront/domain/verify)
        
        
Backend looks up the TXT record via DNS
        
   ┌────┴─────┐
    found       CustomDomainVerified = true
    not found   HTTP 422, retry after DNS propagates
   └──────────┘
        
        
Public storefront resolves by the verified domain
   GET /public/storefronts/mystore.com  ─►  same store as the slug

The two DNS records

#PurposeTypeHost / NameValueVerified by Sendy
1RoutingCNAME (subdomain) or A (root domain)the custom domain, e.g. mystore.comapi.sendyiq.com (the customDomainTarget returned by the API)No — guidance only
2OwnershipTXT_sendy-verify.{domain}sendy-verify={customDomainVerificationToken}Yes — checked on verify

Root vs subdomain: Most DNS providers do not allow a CNAME on a root/apex domain (mystore.com). For a root domain, use an A record pointing at the Sendy server IP, or connect a subdomain (shop.mystore.com) with a CNAME. The verification (TXT) step is the same in both cases.

The verification check only validates record #2 (TXT). Record #1 (CNAME/A) is what actually makes the domain serve traffic; it is shown as setup instructions and is not a gate on verification.


Step-by-step flow

Step 1 — Enter the custom domain

PUT /api/v1/store/storefront
Permission: store.storefront.manage
Content-Type: multipart/form-data

Request (other storefront fields omitted for brevity):

{
  "customDomain": "mystore.com"
}

Behavior:

  • The domain is normalized (trimmed + lowercased).
  • A fresh customDomainVerificationToken (32-char hex) is generated.
  • customDomainVerified is reset to false.
  • Setting customDomain to null / empty clears the domain, the token, and the verified flag.
  • Changing the domain to a new value regenerates the token and resets verification.

Response (storefront fields trimmed to the domain-related ones):

{
  "success": true,
  "message": "Storefront settings updated.",
  "data": {
    "id": "guid",
    "slug": "string",
    "customDomain": "mystore.com",
    "customDomainVerified": false,
    "customDomainVerificationToken": "9f1c0e7a4b2d4e8f9a0b1c2d3e4f5a6b",
    "customDomainTarget": "api.sendyiq.com"
  }
}

customDomainTarget is a per-deployment constant from configuration (Application:CustomDomainTarget, falling back to the BackendBaseUrl host). The dashboard displays it as the CNAME "Points to" value so the store knows where to route.

If another store has already verified the same domain:

HTTP 409
{
  "success": false,
  "message": "This domain is already in use by another store.",
  "code": "CUSTOM_DOMAIN_TAKEN"
}

Step 2 — Add the two DNS records

At the domain provider, the store creates:

Type    Host / Name             Value
CNAME   mystore.com             api.sendyiq.com
TXT     _sendy-verify           sendy-verify=9f1c0e7a4b2d4e8f9a0b1c2d3e4f5a6b

DNS changes can take up to 24 hours to propagate.

Step 3 — Verify ownership

POST /api/v1/store/storefront/domain/verify
Permission: store.storefront.manage

No request body required. The backend resolves the TXT records for _sendy-verify.{customDomain} and checks for a value equal to sendy-verify={token}.

Success — TXT record found:

HTTP 200
{
  "success": true,
  "message": "Custom domain verified successfully.",
  "data": {
    "customDomain": "mystore.com",
    "customDomainVerified": true,
    "customDomainVerificationToken": "9f1c0e7a4b2d4e8f9a0b1c2d3e4f5a6b",
    "customDomainTarget": "api.sendyiq.com"
  }
}

customDomainVerifiedAt is stamped server-side at this point.

Failure cases:

ConditionHTTPCodeMessage
No domain or token configured400DOMAIN_NOT_CONFIGUREDNo custom domain or verification token configured.
Domain already verified400DOMAIN_ALREADY_VERIFIEDCustom domain is already verified.
TXT record not present yet422DNS_TXT_NOT_FOUNDTXT record not found. DNS propagation can take up to 24 hours.

On a 422, the store should confirm the TXT record and retry once DNS has propagated.

Step 4 — Public storefront resolves by the verified domain

The public storefront endpoints accept either the slug or a verified custom domain as the {slug} path identifier:

GET    /api/v1/public/storefronts/{slug}
GET    /api/v1/public/storefronts/{slug}/catalog
POST   /api/v1/public/storefronts/{slug}/orders
POST   /api/v1/public/storefronts/{slug}/orders/otp

Lookup condition (applied in every public storefront query):

(Slug == identifier OR (CustomDomain == identifier AND CustomDomainVerified))
AND not soft-deleted
AND IsStorefrontPublished

So once mystore.com is verified and the storefront is published, GET /api/v1/public/storefronts/mystore.com returns the same store as its slug. An unverified domain never resolves.


Infrastructure prerequisite (out of application scope)

The application delivers the DNS instructions, ownership verification, and domain-based lookup. For https://mystore.com to actually serve in the browser, the edge / reverse proxy in front of api.sendyiq.com must additionally:

  1. Accept the custom Host header and forward it to the API.
  2. Terminate TLS for the custom domain — e.g. issue an on-demand certificate (Caddy on-demand TLS, an ACME companion, or a managed edge) once the domain is verified.

Without (2), the domain resolves at the DNS level but the browser will reject the TLS handshake. Certificate / proxy provisioning is an operations task, not part of this backend flow.


State model

FieldTypeSet when
CustomDomainstring | nullStore enters a domain via PUT /storefront
CustomDomainVerificationTokenstring | nullGenerated on domain save; cleared when domain cleared
CustomDomainVerifiedbooltrue after a successful verify; reset to false when the domain changes
CustomDomainVerifiedAtdatetime | nullStamped on successful verify
CustomDomainTargetstring (response only)Read from config (Application:CustomDomainTarget) — not stored per-store

Backfill note: stores that had a CustomDomain set before verification existed have a null token. GET /api/v1/store/storefront lazily generates and persists a token for such rows so the dashboard can render the DNS instructions.


Configuration

// appsettings.json
"Application": {
  "BackendBaseUrl": "https://api.sendyiq.com",
  "FrontendBaseUrl": "https://sendyiq.com",
  "CustomDomainTarget": "api.sendyiq.com"   // CNAME "Points to" value shown to stores
}

If CustomDomainTarget is omitted, the API falls back to the host of BackendBaseUrl.


Permissions reference

PermissionWho has itWhat it allows
store.storefront.viewstore_owner, store_staffRead storefront settings (incl. domain status + token)
store.storefront.managestore_ownerSet the custom domain and trigger verification

Public storefront endpoints require no authentication.


Endpoints summary

MethodRouteAuthPurpose
PUT/api/v1/store/storefrontJWT · store.storefront.manageSet/clear custom domain, get token + target
POST/api/v1/store/storefront/domain/verifyJWT · store.storefront.manageVerify ownership via TXT lookup
GET/api/v1/store/storefrontJWT · store.storefront.viewRead domain status + token
GET/api/v1/public/storefronts/{slug}noneResolve by slug or verified custom domain

Full sequence diagram

Store Owner            Dashboard / API            DNS Provider         Public Visitor
                                                                         
       PUT /store/storefront                                             
       { customDomain }                                                  
     │──────────────────────►│                                             
       token + customDomainTarget                                         
     │◄──────────────────────│                                             
                                                                         
       add CNAME  mystore.com  api.sendyiq.com                            
       add TXT    _sendy-verify  sendy-verify={token}                     
     │────────────────────────────────────────────────►│                    
                                                                         
       POST /store/storefront/domain/verify                               
     │──────────────────────►│  DNS TXT lookup                             
                            │────────────────────────►│                    
                              _sendy-verify value                         
                            │◄────────────────────────│                    
       { customDomainVerified: true }                                      
     │◄──────────────────────│                                             
                                                                         
                                 GET /public/storefronts/mystore.com       
                            │◄─────────────────────────────────────────────│
                                 storefront (resolved by verified domain)  
                            │──────────────────────────────────────────────►

Source: DOCS/flows/store-custom-domain-flow.md