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:
- Routing record (CNAME / A) — points the domain at the Sendy server so traffic arrives at the API with
Host: mystore.com. - 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
| # | Purpose | Type | Host / Name | Value | Verified by Sendy |
|---|---|---|---|---|---|
| 1 | Routing | CNAME (subdomain) or A (root domain) | the custom domain, e.g. mystore.com | api.sendyiq.com (the customDomainTarget returned by the API) | No — guidance only |
| 2 | Ownership | TXT | _sendy-verify.{domain} | sendy-verify={customDomainVerificationToken} | Yes — checked on verify |
Root vs subdomain: Most DNS providers do not allow a
CNAMEon a root/apex domain (mystore.com). For a root domain, use anArecord pointing at the Sendy server IP, or connect a subdomain (shop.mystore.com) with aCNAME. 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. customDomainVerifiedis reset tofalse.- Setting
customDomaintonull/ 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"
}
}
customDomainTargetis a per-deployment constant from configuration (Application:CustomDomainTarget, falling back to theBackendBaseUrlhost). 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:
| Condition | HTTP | Code | Message |
|---|---|---|---|
| No domain or token configured | 400 | DOMAIN_NOT_CONFIGURED | No custom domain or verification token configured. |
| Domain already verified | 400 | DOMAIN_ALREADY_VERIFIED | Custom domain is already verified. |
| TXT record not present yet | 422 | DNS_TXT_NOT_FOUND | TXT 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:
- Accept the custom
Hostheader and forward it to the API. - 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
| Field | Type | Set when |
|---|---|---|
CustomDomain | string | null | Store enters a domain via PUT /storefront |
CustomDomainVerificationToken | string | null | Generated on domain save; cleared when domain cleared |
CustomDomainVerified | bool | true after a successful verify; reset to false when the domain changes |
CustomDomainVerifiedAt | datetime | null | Stamped on successful verify |
CustomDomainTarget | string (response only) | Read from config (Application:CustomDomainTarget) — not stored per-store |
Backfill note: stores that had a
CustomDomainset before verification existed have anulltoken.GET /api/v1/store/storefrontlazily 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
| Permission | Who has it | What it allows |
|---|---|---|
store.storefront.view | store_owner, store_staff | Read storefront settings (incl. domain status + token) |
store.storefront.manage | store_owner | Set the custom domain and trigger verification |
Public storefront endpoints require no authentication.
Endpoints summary
| Method | Route | Auth | Purpose |
|---|---|---|---|
PUT | /api/v1/store/storefront | JWT · store.storefront.manage | Set/clear custom domain, get token + target |
POST | /api/v1/store/storefront/domain/verify | JWT · store.storefront.manage | Verify ownership via TXT lookup |
GET | /api/v1/store/storefront | JWT · store.storefront.view | Read domain status + token |
GET | /api/v1/public/storefronts/{slug} | none | Resolve 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