Skip to content

Set a custom domain

PUT
/status-pages/{id}/domain
curl --request PUT \
--url https://app.tunnelhq.com/api/v1/status-pages/sp_12/domain \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <X-API-Key>' \
--header 'X-Project-Id: prj_12' \
--data '{ "domain": "status.larkspur.example" }'

Sets the page’s custom domain, such as status.example.com or a bare example.com. An empty string removes it, and the page stops answering on it at once. The page’s own address keeps working either way.

The page that comes back lists the record the domain needs in dns_records, named as DNS providers name it: one CNAME to cname.tunnelhq.net, named status for status.example.com or @ for a bare example.com. A bare domain can’t hold an ordinary CNAME, so add it as your DNS provider’s CNAME flattening, ALIAS or ANAME record, or use a subdomain instead. On Cloudflare, set the record to DNS only (grey cloud). Records made earlier that point at tunnelhq.net or status.tunnelhq.com keep working.

Then check the domain. It needs a role that can edit the project (Owner, Admin and Manager have it by default).

Need an API key? See Getting an API key.

id
required
string

The page’s id (sp_12), from List status pages. The bare number (12) works too.

Example
sp_12
X-Project-Id
required
string

The project, as prj_12 or 12. A workspace key can only reach its own workspace’s projects.

Example
prj_12
Media typeapplication/json
object
domain
required

A domain name, or "" to remove the custom domain.

string
Example
{
"domain": "status.larkspur.example"
}

The whole page, as Get a status page returns it, with domain_state waiting and its dns_records.

Media typeapplication/json
object
id
required
string
name
required
string
slug
required
string
address
required

The page’s own address, without https://.

string
url
required

Where visitors find the page. It uses the custom domain only when the page is published and domain_state is active.

string
published
required
boolean
published_at
required
string | null format: date-time
changes_pending
required

A published page has changes that wait for a publish.

boolean
custom_domain
required
string | null
domain_state
required
  • none: no custom domain is set.
  • waiting: set, but its record doesn’t point to TunnelHQ yet. domain_note says what the check found.
  • issuing: the record points here and the HTTPS certificate is being made.
  • active: live, with HTTPS.
  • failed: the certificate failed. domain_note says why.
string
Allowed values: none waiting issuing active failed
domain_note
required

What the last check found

string | null
dns_records
required

The record the custom domain needs, one CNAME to cname.tunnelhq.net; empty without a custom domain. For a bare domain it’s named @: add it as your DNS provider’s CNAME flattening, ALIAS or ANAME record.

Array<object>

The record a custom domain needs, named as DNS providers name it: relative to your zone, status for status.example.com and @ for example.com itself.

object
type
required
string
Allowed values: CNAME
name
required
string
value
required
string
private
required

Whether the page is behind a password.

boolean
website_url
string | null
contact_url
string | null
description
string | null
page_title
string | null
brand_color

Lowercase #rrggbb, or null.

string | null
/^#[0-9a-f]{6}$/
theme
string
Allowed values: auto light dark
header

logo: the logo and Subscribe. site: with Status, History and Maintenance tabs too.

string
Allowed values: logo site
layout

bars: 90 days of bars under each service. folded: one line each, bars on a click.

string
Allowed values: bars folded
show_past_incidents
boolean
logo_height
integer
Allowed values: 24 32 40 48
logo_with_name
required

Whether the name shows beside the logo now: your choice, or until you make one, the logo’s shape.

boolean
language
string
Allowed values: en fr de es pt-br nl pl sv ru tr fa ar zh-cn
languages

Other languages visitors can choose.

Array<string>
Allowed values: en fr de es pt-br nl pl sv ru tr fa ar zh-cn
search_visible
boolean
auto_incidents
boolean
monitoring_operational
boolean
templates

The text a new update starts with, for each status. Sending it replaces the whole set; a status left out is empty.

object
investigating
string
<= 1000 characters
identified
string
<= 1000 characters
monitoring
string
<= 1000 characters
resolved
string
<= 1000 characters
has_logo
boolean
has_dark_logo
boolean
has_favicon
boolean
has_share_image
boolean
service_count
required

Services as visitors see them, hidden ones included: a server’s tunnels on one host count once, a group counts its members.

integer
service_limit
required
integer
sections
required
Array<object>
object
id
required
integer
name
required

The section’s heading; empty for none.

string
services
required
Array<object>
object
id
required
string
monitor_id
required
string
name
required

The name shown on the page.

string
hidden
required

On the page’s setup, but not shown to visitors.

boolean
monitor_name
required

The monitor’s own name.

string
type
required

The monitor’s type, such as vpn, http or group.

string
protocol
required

A tunnel’s protocol, such as wireguard; null for other monitors.

string | null
services
required

A group’s members, one level deep. Empty for anything else.

Array<object>
object
key
additional properties
any
Examples
Exampledefault

A draft status page

{
"id": "sp_12",
"name": "Larkspur VPN",
"slug": "larkspur-vpn",
"address": "larkspur-vpn.tunnelhq.net",
"url": "https://larkspur-vpn.tunnelhq.net",
"published": false,
"published_at": null,
"changes_pending": false,
"custom_domain": "status.larkspur.example",
"domain_state": "waiting",
"domain_note": null,
"dns_records": [
{
"type": "CNAME",
"name": "status",
"value": "cname.tunnelhq.net"
}
],
"private": false,
"website_url": null,
"contact_url": null,
"description": null,
"page_title": null,
"brand_color": null,
"theme": "auto",
"header": "site",
"layout": "bars",
"show_past_incidents": true,
"logo_height": 32,
"logo_with_name": false,
"language": "en",
"languages": [],
"search_visible": true,
"auto_incidents": true,
"monitoring_operational": false,
"templates": {
"investigating": "",
"identified": "",
"monitoring": "",
"resolved": ""
},
"has_logo": false,
"has_dark_logo": false,
"has_favicon": false,
"has_share_image": false,
"service_count": 2,
"service_limit": 400,
"sections": [
{
"id": 3,
"name": "",
"services": [
{
"id": "sps_31",
"monitor_id": "srv_40",
"name": "Europe",
"hidden": false,
"monitor_name": "Europe",
"type": "group",
"protocol": null,
"services": [
{
"id": "sps_32",
"monitor_id": "srv_42",
"name": "Frankfurt 1",
"hidden": false,
"monitor_name": "Frankfurt 1 WireGuard",
"type": "vpn",
"protocol": "wireguard",
"services": []
},
{
"id": "sps_33",
"monitor_id": "srv_43",
"name": "Frankfurt 1",
"hidden": false,
"monitor_name": "Frankfurt 1 OpenVPN",
"type": "vpn",
"protocol": "openvpn",
"services": []
}
]
},
{
"id": "sps_34",
"monitor_id": "srv_80",
"name": "larkspur.example",
"hidden": false,
"monitor_name": "larkspur.example",
"type": "http",
"protocol": null,
"services": []
}
]
}
]
}
X-RateLimit-Limit
integer

Requests the workspace’s plan allows per minute.

X-RateLimit-Remaining
integer

Requests left in the current minute.

X-RateLimit-Reset
integer

Unix time, in seconds, when the current minute ends.

domain isn’t a usable domain name, or it’s one of TunnelHQ’s.

Media typeapplication/json
object
error
required
boolean
code
required

The HTTP status, repeated.

integer
message
required

What went wrong.

string
details

More detail, on some errors.

retry_after

Seconds to wait, on the 429 for wrong keys.

integer
Examples
{
"error": true,
"code": 400,
"message": "Enter a domain name, such as status.example.com."
}

The API key is missing, wrong, expired, switched off, or no longer valid.

Media typeapplication/json
object
error
required
boolean
code
required

The HTTP status, repeated.

integer
message
required

What went wrong.

string
details

More detail, on some errors.

retry_after

Seconds to wait, on the 429 for wrong keys.

integer
Examples
{
"error": true,
"code": 401,
"message": "API key is required. Provide X-API-Key header or Authorization: Bearer <key>"
}

The role can’t edit the project.

Media typeapplication/json
object
error
required
boolean
code
required

The HTTP status, repeated.

integer
message
required

What went wrong.

string
details

More detail, on some errors.

retry_after

Seconds to wait, on the 429 for wrong keys.

integer
Example
{
"error": true,
"code": 403,
"message": "Changing status pages needs a role that can edit this project."
}

The project has no status page with this ID.

Media typeapplication/json
object
error
required
boolean
code
required

The HTTP status, repeated.

integer
message
required

What went wrong.

string
details

More detail, on some errors.

retry_after

Seconds to wait, on the 429 for wrong keys.

integer
Example
{
"error": true,
"code": 404,
"message": "No such status page."
}

Another status page already uses this domain.

Media typeapplication/json
object
error
required
boolean
code
required

The HTTP status, repeated.

integer
message
required

What went wrong.

string
details

More detail, on some errors.

retry_after

Seconds to wait, on the 429 for wrong keys.

integer
Example
{
"error": true,
"code": 409,
"message": "Another status page already uses that domain."
}

A usage limit was reached, or this IP address made too many requests with a wrong key.

Usage limits are per workspace and plan: per minute (Retry-After: 60), per day (Retry-After: 3600), and per month (no Retry-After). They use their own body shape. An IP address may send 10 wrong keys a minute; one more is allowed every 6 seconds. While that allowance is used up, every request from the address is refused, even one with a valid key.

Media typeapplication/json
One of:
object
error
required
string
Allowed values: Rate limit exceeded Daily limit exceeded Monthly limit exceeded
message
required
string
retry_after

Seconds to wait. Absent for the monthly limit.

integer
Examples
{
"error": "Rate limit exceeded",
"message": "Per-minute limit of 60 requests exceeded",
"retry_after": 60
}
Retry-After
integer

Seconds to wait. 60 for the per-minute limit, 3600 for the daily limit, 6 after wrong keys. Absent for the monthly limit.

Something failed on TunnelHQ’s side.

Media typeapplication/json
object
error
required
boolean
code
required

The HTTP status, repeated.

integer
message
required

What went wrong.

string
details

More detail, on some errors.

retry_after

Seconds to wait, on the 429 for wrong keys.

integer
Example
{
"error": true,
"code": 500,
"message": "Internal server error"
}