Jump to a section
Everything Pulse does, and how to drive it.
Pulse is API first. Every button in the panel is a REST call you can make yourself, the probe that runs every check is open source, and every node we run is published so you can measure it. Your monitors live in the Pulse control plane, created in the panel, over the API, or from a YAML file you apply from CI. Scrape it into Prometheus, drop a looking glass on any node, watch a cert count down. Free for personal and commercial use, no card, no trial timer.
Overview
Pulse runs HTTP, TCP, ICMP ping, DNS, TLS certificate and heartbeat checks from 6 independent regions and records the full timing breakdown per region. State changes are confirmed across regions before an alert fires, so a single-region blip never pages you. The control plane is AWS serverless; the probes are real machines on budget networks, deliberately not cloud only, so you see the internet the way your users do.
Base URL
https://api.pulse.corehost.ioAuth
Cognito JWT sent as Authorization: Bearer <id_token>. Scoped tokens (plssk_live_...) for Prometheus and automation. Probe tokens use x-probe-token. Heartbeat, badges and status pages are public.
Format
JSON in, JSON out. Timestamps ISO 8601 UTC. Latency in milliseconds. Errors are {"error":"..."}.
Limits
100 monitors and 15 second checks by default. Need more monitors? Ask us and we raise the limit, still free.
Quickstart: your first monitor
Two minutes from signup to a monitor checking your site from every Pulse location. Nothing to install, nothing to configure on your servers.
Create an account
At signup, either Continue with Google or GitHub, or an email and a password (we email you a six-digit code to confirm the address). No card.
Add a monitor
In the panel, at pulse.corehost.io/app, choose New monitor. Give it a name, a type (start with
http), and the URL. The interval defaults to 60 seconds and can go down to 15. Optionally add a content assertion on the body and a TLS expiry warning.Pick where it checks from
Choose any subset of regions, or leave it on all of them (the default). This is the region picker: see Regions below. Monitors bound to a private probe skip this, the probe is the source.
Wire an alert channel
Connect Discord, Slack, Telegram, PagerDuty, email or any of the 17 channel types, then send a test alert to prove it before you rely on it.
Watch the timing land
Within one interval you get per-region DNS, connect, TLS and first-byte numbers, and a rollup history. That is your baseline.
Prefer the terminal or CI? Skip the panel and use monitors as code or the CLI. Everything in the UI exists in the API first.
Where monitors live
Pulse holds one list of monitors, and every check that runs anywhere comes from it. You can drive that list from a YAML file in your repo, from the panel, or from the API.
The control plane is the single source of truth. Whichever way you create a monitor, it lands in the same place, and both our shared network and any private probe you install are told what to check by asking Pulse for it over HTTPS. A probe holds no monitor list of its own and reads no configuration file.
| Way in | What it is |
|---|---|
| Panel | New monitor in the panel. Quickest for one-off checks. |
| API | POST /v1/monitors with your session token. See the API reference. |
| CLI | pulsectl apply reads a YAML file in your own repo and upserts each entry by name. The way to keep monitors in version control and apply them from CI. See monitors as code. |
| Import | An Uptime Kuma export, mapped and created once. |
monitors.yaml on a Pulse probe. A probe you install is given its monitors by the control plane every 30 seconds over HTTPS, authenticated with its probe token, and it never looks on disk for them. A probe that is missing its token says so and stops; it does not go looking for a file. See where a probe gets its monitors. This is not a limit on monitors as code, which is a supported way to run Pulse: that YAML lives in your own repository, next to the thing it watches, and applying it creates the monitors through the API. The distinction is only about location. Your file, your repo, never the probe host. Check types
| Type | What it does | Assertions |
|---|---|---|
| http | GET a URL, full DNS / connect / TLS / TTFB timing. On https:// it also reads the certificate on the same handshake | status code, content assertions (contains / regex / JSON path), TLS days remaining, cert chain valid |
| tcp | Open a TCP port, measure connect time | reachable, connect latency |
| ping | Send ICMP echo packets, record loss and round-trip stats (see Ping monitors) | reply received, packet-loss threshold |
| dns | Resolve a record from each region (see DNS record checks) | record type (A / AAAA / CNAME / MX / TXT / NS), expected answer, change detection |
| tls_cert | Open a TLS handshake to host:port and read the leaf and chain | days to expiry, chain and hostname valid, weak cipher |
| heartbeat | Your job checks in on a schedule; we watch for silence | fires if no check-in inside the grace window |
HTTP, TCP and TLS checks record DNS, connect, TLS and first-byte time separately per region; ping records loss and round-trip stats instead. All of them feed p50 / p95 / p99 and jitter over the window (see Latency and percentiles). That is the timing breakdown on a monitor's detail page, and the reason a slow region stands out from a down one. Outbound checks can also be pinned to IPv4 or IPv6.
Content assertions
A 200 from a broken app is still a 200. Assert on what the body actually says.
An http monitor can go beyond the status code and check the response body itself: a substring, a regex, or a value inside a JSON payload. Every assertion you set must pass; the first one that misses fails the check with a specific error beginning assertion failed: ..., which then rides the normal confirm_checks streak like any other failure. Assertions read at most the first 512 KB of the body; monitors with no assertions never read the body at all.
| Field | Limit | Passes when |
|---|---|---|
| assert_contains | 1024 chars | The body contains the string, byte for byte. |
| assert_regex | 512 chars | The pattern matches the body. Probes run Go's RE2 engine (linear time, no backtracking blowup), so lookaround and backreferences are rejected with a 400 when you create the monitor, never shipped to a probe that cannot run them. |
| assert_json_path | 256 chars | The body parses as JSON and the dot path resolves. Index arrays with a numeric segment: items.0.name. On its own this is an existence check. |
| assert_json_equals | 1024 chars | The value at assert_json_path equals this, compared as text: strings as themselves, numbers as 200 or 1.5, booleans as true / false, null as null. Requires assert_json_path. |
One curl per field. Substring:
curl -X POST https://api.pulse.corehost.io/v1/monitors \
-H "Authorization: Bearer <id_token>" \
-d '{"name":"shop-checkout","type":"http",
"url":"https://shop.example.com/checkout",
"assert_contains":"Add to cart"}'
# fails with: assertion failed: body does not contain "Add to cart" (first 512KB checked)Regex, RE2-safe:
curl -X POST https://api.pulse.corehost.io/v1/monitors \
-H "Authorization: Bearer <id_token>" \
-d '{"name":"build-info","type":"http",
"url":"https://app.example.com/version",
"assert_regex":"\"build\":\"[0-9a-f]{7}\""}'
# lookaround and backreferences 400 at create time: probes run RE2JSON path existence:
curl -X POST https://api.pulse.corehost.io/v1/monitors \
-H "Authorization: Bearer <id_token>" \
-d '{"name":"api-token-field","type":"http",
"url":"https://api.example.com/session/probe",
"assert_json_path":"data.session.token"}'
# fails with: assertion failed: json path "data.session.token" not foundJSON path equality:
curl -X POST https://api.pulse.corehost.io/v1/monitors \
-H "Authorization: Bearer <id_token>" \
-d '{"name":"api-health","type":"http",
"url":"https://api.example.com/health",
"assert_json_path":"status",
"assert_json_equals":"ok"}'
# fails with: assertion failed: json path "status" is "degraded", want "ok" Assertions are independent: set any combination and all of them must hold. An empty or absent field is simply off, so existing monitors change nothing. The older keyword field keeps working and is equivalent to assert_contains; use the assertion fields for anything new. The exact failure reason lands in the check's error, so the alert tells you what the body said, not just that something was wrong.
DNS record checks
Watch a record from multiple regions and get paged when the zone stops serving what you expect.
A dns monitor resolves one record type for a hostname from every region you pick. It fails when the type does not resolve, when it resolves to nothing, or, with an expected value set, when the answer set no longer contains that value. That last mode is change detection: point it at your apex A record or your MX and a hijacked, fat-fingered or expired zone fails the check within one interval.
curl -X POST https://api.pulse.corehost.io/v1/monitors \
-H "Authorization: Bearer <id_token>" \
-d '{"name":"corehost-mx","type":"dns",
"host":"corehost.io",
"dns_record_type":"MX",
"dns_expected":"mail.corehost.io"}'
# fails with: expected MX "mail.corehost.io" not in answers [...]| Field | Default | Meaning |
|---|---|---|
| host | - | The name to resolve. Required. |
| dns_record_type | A | One of A, AAAA, CNAME, MX, TXT, NS. |
| dns_expected | off | The check fails when no answer matches this value. Omit it and any non-empty answer set passes. |
Matching is type-aware, so cosmetic differences never page you:
| Type | An answer matches when |
|---|---|
| A / AAAA | It is the same IP, compared as a parsed address, so ::1 equals 0:0::1. |
| CNAME / NS | The name is equal after case folding and trailing-dot removal: Mail.Example.COM. equals mail.example.com. |
| MX | The exchange host matches; a copied 10 mail.example.com value is matched on its host part, priority ignored. |
| TXT | The record contains the expected value as a substring, so you can pin one key of a long SPF string. |
Every check records the full answer set it saw, sorted for a stable order, even when the match then fails, so the monitor's history shows what the zone actually served, not just that it differed. Multiple regions resolving independently also catch the split-horizon case where one resolver hands out a stale or poisoned answer the others do not. DNS timing lands in the same per-region rollups as every other check.
Ping monitors
An ICMP echo from every region you pick: loss, round-trip stats, and a threshold you set.
A ping monitor sends a burst of ICMP echo packets to a hostname or literal IP each interval and records what came back. It is the right check for a router, a firewall, a bare host, anything that answers ICMP but has no port worth opening.
POST https://api.pulse.corehost.io/v1/monitors
{"name":"edge-router","type":"ping",
"host":"203.0.113.1","interval_s":60,
"loss_threshold_pct":20} # fail the check when loss exceeds 20%| Field | Default | Meaning |
|---|---|---|
| host | - | Hostname or literal IPv4 / IPv6 address to echo. Required. |
| loss_threshold_pct | 0 | 0 means any reply counts as up. Set 1 to 100 and the check fails when observed loss exceeds it; the RTT of the replies that did return is still recorded. |
Each interval is one multi-packet echo round per region. What a round produces:
| What | How it works |
|---|---|
| Round outcome | Up when a reply came back; with a threshold set, up only when observed loss stayed at or under it. A fully lost round records a failure with no latency sample, never a fabricated number. |
| Latency | The round's average RTT feeds the same per-region rollups and p50 / p95 / p99 as every other check type. |
| Loss over the window | The monitor summary reports the share of failed rounds. Rounds are the stored granularity, so that figure is real, not a per-packet number invented from it. |
The ip_version pin applies: force "4" or "6", or leave "auto", where a ping runs once per interval and records the family the reply actually came from rather than being doubled into a v4-and-v6 pair, because ICMP is several packets over a few seconds and running it twice from every region is needless load. Targets follow the same rules as every shared-network monitor: private, loopback and reserved addresses are refused, so ping something internal from a private probe instead. And remember what ping measures: some hosts rate limit or deprioritize ICMP, so a lossy ping against a healthy HTTP check describes the ICMP path, not the service.
Regions: where checks run from
The shared network is 6 machines in cities around the world, listed below. When you create a monitor you choose which of them check it. Leave the picker empty and every region checks it; pick a subset to focus on the geographies your users are in, or to cut noise from a region that reaches a regional service slowly on purpose.
| Code | City | Country | Region label |
|---|---|---|---|
| ams | Amsterdam | NL | eu-west |
| ash | Ashburn | US | us-east |
| lax | Los Angeles | US | us-west |
| lon | London | GB | eu-west-2 |
| sin | Singapore | SG | ap-southeast |
| sto | Stockholm | SE | eu-north |
Pass regions on create, or omit for all of them:
POST https://api.pulse.corehost.io/v1/monitors
Authorization: Bearer <id_token>
{"name":"corehost-web","type":"http",
"url":"https://corehost.io","interval_s":60,
"regions":["ams","lon"]} # omit or [] = all regionsRegions apply to shared-network monitors only. A monitor bound to a private probe ignores regions: that one probe is its only vantage point, so the panel hides the picker. The public status feed and rollups key on the region, so a monitor simply produces rows for the regions that check it, nothing for the rest.
TLS certificate monitoring
Know a certificate is about to expire days before it takes the site down, not when the pager goes off.
There are two ways to watch a certificate, and they share one data shape and one alert model. Any http monitor on an https:// URL already reads the presented certificate on the same handshake it uses to time the request, so you get cert data for free on every HTTPS monitor. To watch a certificate on a port you do not otherwise poll, add a dedicated tls_cert monitor.
POST https://api.pulse.corehost.io/v1/monitors
{"name":"corehost-cert","type":"tls_cert",
"host":"corehost.io","port":443,
"interval_s":300,"cert_warn_days":14} # warn 14 days out (default)Each check records the leaf and chain, honestly. Anything the probe cannot determine is null, never guessed:
| Field | Meaning |
|---|---|
| days_remaining | Whole days to expiry, floored. Goes negative once expired. |
| issuer / subject | The issuing CA and the leaf subject. |
| alt_names | The SANs on the leaf. |
| chain_ok | Chain validates to a trusted root and the hostname matches a SAN. |
| weak_cipher | True for SSLv3 / TLS 1.0 / 1.1, RC4, 3DES, or an RSA key under 2048 bits. |
| tls_version / cipher | The negotiated protocol and cipher suite. |
The state is derived server side and drives the alert. In order of severity:
| State | When |
|---|---|
| expired | days_remaining is below zero. |
| invalid | Chain does not validate, hostname mismatch, or a weak cipher. |
| critical | Three days or fewer remaining. |
| warn | Inside your cert_warn_days threshold. |
| ok | Valid chain, comfortably in date. |
A cert alert fires on a worsening transition into warn, critical, expired or invalid, through the same alert channels as any other monitor, and resolves when the certificate returns to ok. The alert title reads "corehost-cert certificate expires in 9 days" or "...chain is invalid".
A certificate with days left on it still serves every visitor, so warn and critical send as degraded: amber, PagerDuty severity warning, Opsgenie P3. Only expired and invalid send as down at your configured severity, because those genuinely break the connection. Certificate events also carry their own pager key (pulse-<monitor>-cert), so renewing a certificate cannot resolve an open outage incident. Either way the monitor's own up/down is untouched: it is derived from whether checks succeed, so a certificate warning never dents an uptime figure.
cert_warn_days is per monitor. The read costs nothing extra: the probe takes the leaf and chain from the TLS handshake the check already performs, so there is no second connection to your host.
Latency and percentiles
An average hides the tail, and the tail is where your users feel pain. Pulse keeps the raw round-trip time of every check in a minute slot, per region, and computes real percentiles from them. A monitor's rollup carries p50_ms, p95_ms, p99_ms, min_ms, max_ms and jitter_ms (the standard deviation) alongside the average, all from the actual samples in that minute.
For a smooth chart over a longer window, the percentiles endpoint pools the raw samples across each display bucket, so p95 and p99 are computed over a real, larger set rather than one thin minute:
GET https://api.pulse.corehost.io/v1/monitors/corehost-web/percentiles?hours=24&buckets=48
Authorization: Bearer <id_token>
# -> {"points":[{"ts":"...","count":120,"p50":23,"p95":58,"p99":84,"jitter":9}, ...]}Pass region to focus one node, or omit it to pool every region. A bucket with no samples reports count:0 and null metrics, and the chart draws a gap. Nothing is interpolated: a break in the line is a real break in the data, never a smoothed-over guess. This is the exact feed behind the percentile chart on a monitor's detail page.
Latency-threshold alerts
Up is not the whole story. Get paged when a healthy monitor turns slow.
Give a monitor a latency_threshold_ms and it runs a second state machine next to up/down: a successful check whose total round-trip time is above the threshold counts as a breach. Breaches ride the exact same confirm_checks streak as failures, so when the streak commits, the monitor is marked degraded and an alert goes out through your normal channels; once latency holds under the threshold for the same streak, a recovery notice follows.
curl -X POST https://api.pulse.corehost.io/v1/monitors \
-H "Authorization: Bearer <id_token>" \
-d '{"name":"corehost-web","type":"http",
"url":"https://corehost.io",
"latency_threshold_ms":800,
"confirm_checks":5,
"renotify_interval_s":900}'
# degraded on the 5th consecutive check over 800 ms,
# then re-sent every 15 minutes until latency recovers| Field | Default | Range | Meaning |
|---|---|---|---|
| latency_threshold_ms | 0 (off) | 0..60000 | A successful check counts as breaching when its total time exceeds this. Applies to every check type that measures latency; heartbeats have none. |
How the pieces you already know apply:
| Interaction | Behaviour |
|---|---|
confirm_checks | The same streak, the same fast-interval clamp. Breaches must hold for the configured count of consecutive checks before the degraded flip commits, and recovery must hold just as long, so one slow sample never pages you and a monitor hovering at the threshold resolves once. |
| Regions | The streak is counted across the interleaved results from every region checking the monitor, exactly like up/down confirmation. A single-region latency spike is broken up by the other regions' healthy checks; a slowdown has to be broad or persistent enough to breach consecutively. The alert names every region that contributed a breach and the worst time observed. |
| Down wins | Failed checks are never latency breaches; they feed the up/down machine. If the monitor goes down mid-episode, the degraded episode closes silently, because the down alert supersedes it, and the latency machine restarts clean after recovery. |
renotify_interval_s | While the monitor stays degraded, the alert re-sends on the same cadence you set for down alerts (0 = off, minimum 300 seconds). |
| Uptime | A degraded check still succeeded, so uptime, SLO and the 90-day bars are untouched. Degraded is an alert about speed, never a fake outage. |
The alert reads "corehost-web is DEGRADED" with the evidence inline: "latency above 800ms: observed 1240ms from ams, lon", where the observed figure is the worst breaching check of the episode and the regions are the ones that actually breached. Recovery sends "corehost-web latency recovered". Set latency_threshold_ms to 0 and the machine switches off and forgets its state. Pick the threshold off the monitor's real p95 / p99, not a hunch: a threshold under the routine p99 is a pager that cries wolf.
IPv4 and IPv6
A dual-stacked host can be perfectly healthy over IPv4 while its AAAA route is black-holed, and a v4-only check will never notice. Pin a monitor's ip_version to see each family for what it is.
| ip_version | Behaviour |
|---|---|
| "auto" | The default. If the target has both an A and an AAAA record, the probe checks both families every interval and posts a row for each. If only one resolves, it checks that one. |
| "4" | Check IPv4 only. If there is no A record the check fails with no A record, a real failure, surfaced not hidden. |
| "6" | Check IPv6 only. No AAAA record fails with no AAAA record. |
POST https://api.pulse.corehost.io/v1/monitors
{"name":"corehost-web","type":"http",
"url":"https://corehost.io","ip_version":"auto"} # both families, one row eachOn "auto" the rollup carries split counters (ipv4_checks / ipv4_fails and the v6 pair), and per-region status rows carry optional ipv4 and ipv6 sub-objects. A family that produced no rows in the window is simply absent, never synthesized, so the v4-vs-v6 split you see is always a real one.
Monitors as code
Keep your monitors in a YAML file, in the repository you already review, and apply it from CI with the CLI. One of the three ways in, alongside the panel and the API, and the one built for version control.
The file is the source of truth for what you want; apply is idempotent and upserts by name, so re-running it never creates duplicates. It lives in your repo, with you, and no probe ever reads it from disk.
Every target in the example below is real, so it applies as written. Download it and try it rather than typing from the page:
$ curl -fsSL https://pulse.corehost.io/pulse.example.yaml -o pulse.yaml $ pulsectl apply pulse.yaml --dry-run
monitors:
- name: api
type: http
url: https://api.example.com/ping
keyword: healthy
interval_s: 30
- name: edge-router
type: ping
host: 203.0.113.1
loss_threshold_pct: 20
renotify_interval_s: 900
- name: nightly-backup
type: heartbeat
period_s: 86400
grace_s: 900$ pulsectl apply pulse.yaml
Already have monitors from the panel? pulsectl export writes them to a file in this same format, so you can start there instead of writing one by hand.
$ pulsectl export pulse.yaml wrote 4 monitor(s) to pulse.yaml
pulsectl, on your laptop or on a CI runner. Apply reads it there and posts each entry to POST /v1/monitors, so what Pulse ends up holding is monitors, not your file. It is never copied to a probe or to our nodes, and no machine you install a probe on needs it. The name is yours to choose; the examples say pulse.yaml. The same YAML drives HTTP, TCP, ping, DNS, TLS certificate and heartbeat checks. Each entry is passed to POST /v1/monitors unchanged, nothing renamed, so every field in the API reference, including confirm_checks and renotify_interval_s, works here too. Lists work in both styles, regions: [ams, lax] or an indented block, and so do nested mappings such as headers.
Previewing and pruning
--dry-run prints the plan and sends nothing, which is the safe thing to put in a pull request check:
$ pulsectl apply pulse.yaml --dry-run create api update edge-router dry run: 1 to create, 1 to update, 0 to delete. Nothing was sent.
By default apply only creates and updates, so a monitor you delete from the file stays in Pulse. Add --prune to make the file the whole truth and delete anything on the account that is not in it.
$ pulsectl apply pulse.yaml --prune
--yes. On top of that, a run that would delete more than half the monitors on the account stops and asks for --force, on the theory that a file which has drifted that far out of sync is more likely the wrong file than the intended change. Names are validated against the same rule the API uses, and the whole file is checked before the first request goes out, so a typo in the last entry fails without half-applying the first nine. Applying is idempotent either way, so a run that stops part way can simply be re-run once the error is fixed.
Private probes
A private probe monitors targets inside your own network: RFC 1918 addresses, a service on localhost, a database behind the firewall, anything the shared network cannot reach. Monitoring a public site needs none of this, because our own network already checks it from every region. It is one small Go binary, outbound only. It opens no inbound ports. It phones home to Pulse over HTTPS, pulls its config, and ships results back. Nothing connects in to you. The source is at github.com/RobWhyte91/pulse-probe, and it is the same code our own probes run.
New to the architecture? How Pulse works walks the whole path, fleet and probe, with diagrams.
Add a probe and watch it connect
The panel walks you through it as a guided flow, so you are never left guessing:
Name it
In Probes, Add probe, give it a label like
officeordb-vpc. Pulse issues a token that startsplsp_live_, shown once. Copy it now; only its hash is stored. That token is the probe's whole identity: it is how Pulse knows which account's monitors to hand out.Install it on Linux (amd64 or arm64)
The panel shows ready-to-paste commands wired to your token. The one-line installer drops a hardened systemd service (DynamicUser, ProtectSystem=strict, auto-restart), and re-running it later upgrades the binary in place:
curl -fsSL https://pulse.corehost.io/install-probe.sh | \ sudo PULSE_PROBE_TOKEN=plsp_live_xxx bash
Or install it on Windows (10 / Server 2019+)
One line in an elevated PowerShell installs the probe as a Windows service that starts on boot, restarts on failure and logs to the Application event log. The token lives in the service configuration, not in a file. There is also a GUI installer,
PulseProbeSetup.exe, linked from the same panel screen; it asks for the token instead:$env:PULSE_PROBE_TOKEN = "plsp_live_xxx" irm https://pulse.corehost.io/install-probe.ps1 | iex
Watch it come online
The panel polls and flips from Waiting for <label> to check in to <label> connected to Pulse the moment the probe makes its first call, showing its version and source IP. No refresh, no guessing whether it worked.
Give it something to check
A new probe checks nothing until you say what it should check, and that is the normal state, not a fault. Create or open a monitor, and on the step that asks where checks run from, pick your probe under Your probes instead of the Pulse locations. The probe picks the change up within 30 seconds and results start arriving.
PULSE_API_URL defaults to https://api.pulse.corehost.io. The probe reaches out to that; it never receives an inbound connection. A Docker one-liner is also generated for you in the panel. Where a probe gets its monitors
From Pulse, and only from Pulse. On start, and every 30 seconds after that, the probe calls GET /v1/probe/config with its token in the x-probe-token header, and runs exactly the monitors that come back. It ships the results to POST /v1/probe/results. There is no configuration file to write, no monitor list on the machine, and nothing to edit after the install command has run. Change a monitor in the panel and the probe has it within half a minute.
The probe reads two environment variables and nothing else: PULSE_PROBE_TOKEN, the token from the Probes page, which tells Pulse which probe this is, and PULSE_API_URL, which is optional and defaults to https://api.pulse.corehost.io. The installer writes both into /etc/pulse-probe.env on Linux, or the service configuration on Windows. When a probe cannot start, it says which of these three things went wrong rather than carrying on quietly:
| What it prints | What it means |
|---|---|
| pulse-probe is not configured: PULSE_PROBE_TOKEN is not set | It started with no token, usually the binary run by hand outside the service. The message names both variables and the page the token comes from. |
| PULSE_PROBE_TOKEN was rejected | The token is not one Pulse knows: the probe was deleted in the panel, or a later install replaced its token. Add a probe and re-run the installer with the token it shows. Restarting cannot fix it, so the service stops rather than looping. |
| Cannot reach the Pulse API | No answer came back, so the token was never checked. Look at outbound HTTPS, DNS and any proxy. This one keeps retrying on its own. |
pulse-probe -config my-monitors.yaml. That is for developing against the probe itself. An installed probe never uses it, never falls back to it, and there is no file for you to create. What the installer actually does
Nothing hidden, and all of it inspectable: the script is install-probe.sh, and reading it before piping it to a shell is entirely reasonable. On Linux with systemd it writes three things and starts one service:
| Path | What it is |
|---|---|
| /usr/local/bin/pulse-probe | The probe binary, downloaded for your architecture (amd64 or arm64). |
| /etc/pulse-probe.env | Two lines, mode 600: PULSE_API_URL and PULSE_PROBE_TOKEN. This is the only state the probe has, and it is credentials, not monitors. |
| /etc/systemd/system/pulse-probe.service | The unit: DynamicUser, ProtectSystem=strict, NoNewPrivileges, CAP_NET_RAW for ICMP, restart on failure, enabled at boot. |
Re-running the installer upgrades the binary in place and rewrites the unit, keeping the existing credentials unless you pass a different token. Pass PULSE_DRY_RUN=1 to have it print exactly that plan and change nothing. A box that already ran the pre-rename pulse-runner service keeps that name, paths and all, so existing installs keep upgrading without re-enrolling; the installer says which name it used. In the product it is simply your probe.
How to tell it worked
Three places agree, and it is worth knowing all three because they fail differently:
| Where | What good looks like |
|---|---|
| The installer itself | It waits up to 20 seconds for the first check-in and ends with a box: the version installed, the service name, and Connected: pulling config. If it says it saw no check-in, the service started but has not reached the API yet. |
| The service on the host | systemctl is-active pulse-probe reports active, and the log carries the check-in line. A probe that stopped on a configuration problem shows failed, with the reason above in systemctl status pulse-probe. |
| The Probes page | The probe shows Online, with its version, source IP and last-seen time. Online means it has checked in within the last 120 seconds; longer than that and it flips to Offline and, unless you turn that off, alerts you. |
The log line to look for names the number of monitors it was handed:
$ journalctl -u pulse-probe -n 20 --no-pager probe mode: 0 monitors (version 4f2c1a9b0d3e7f11, region "office") from https://api.pulse.corehost.io
0 monitors is a healthy fresh probe, not a fault: it means Pulse answered and has nothing bound to this probe yet. Bind a monitor to it and the count follows within 30 seconds. On Windows the equivalent is the PulseProbe service in services.msc, which keeps the token in its own service configuration rather than a file, and its entries in the Application event log; in Docker it is docker logs pulse-probe.
To remove a probe from a Linux host completely:
sudo systemctl disable --now pulse-probe sudo rm -f /usr/local/bin/pulse-probe /etc/pulse-probe.env \ /etc/systemd/system/pulse-probe.service sudo systemctl daemon-reload
A private probe can check internal targets the shared network refuses; see internal network monitoring. Bind a monitor to it and the region picker disappears, because that probe is the single source of truth for those checks. Deleting a probe in the panel while monitors are still bound to it is refused until you move or delete them, so monitors are never silently orphaned.
Internal network monitoring
Monitor the network the internet cannot see: a NAS, a switch, an internal API, from a probe inside your LAN.
Install a private probe on one machine inside your network and every monitor you bind to it is checked from there: private addresses, internal hostnames, management ports. The results ride the same alerting, percentiles, status pages and history as every public monitor, on the same free plan, with no limit on how many probes you run.
Create a monitor, and on the step that asks where the checks run from, pick your probe under Your probes rather than a Pulse location. A monitor runs from the Pulse network or from one probe, never both, so choosing a probe clears the city selection. Type an internal address and the wizard steers you to your probe instead of failing at submit; if you have none yet it points you at the install flow.
A homelab walkthrough
One probe on any always-on box covers the whole LAN. A typical set:
| Monitor | Type | What it proves |
|---|---|---|
| gateway | ping 10.0.0.1 | The LAN path and the router answer; a loss_threshold_pct catches a flapping link before it dies. |
| switch-mgmt | tcp 10.0.0.2:22 | The management plane is up. A connect, nothing logs in; right for gear that cannot run an agent. |
| nas-smb | tcp 10.0.0.40:445 | The NAS answers on the share port, with connect latency per check. |
| internal-api | http + assert_json_path | The app behind the firewall is truly serving, asserted on the body, not just a status code. |
| nas-name | dns nas.internal | Your internal resolver still serves the name you expect. |
| switch-ui-cert | tls_cert 10.0.0.2:443 | The management UI certificate, with an honest self-signed flag and a working expiry countdown. |
| nightly-backup | heartbeat | The 3am job inside the NAS actually ran. A probe proves the NAS answers; only a heartbeat knows the backup finished. |
That last row is the line to draw: reachability and latency of a device is a probe-bound monitor; whether a job inside a host ran is always a heartbeat, even when a probe exists. Heartbeats are outbound HTTPS from your network to Pulse, so they need nothing opened either.
Names resolve on the probe
DNS monitors and hostname targets resolve with the probe host's own resolver, exactly as a shell on that box would. nas.internal, Active Directory names and split-horizon views all work. For split-horizon zones, run the same name as two monitors, one on the shared network and one on the probe, and you see both answers side by side. Pointing a dns check at one specific server (dig @10.0.0.53 style) is not supported yet.
Internal TLS and private CAs
A tls_cert monitor reads any certificate, self-signed included: state and expiry are reported honestly. An http monitor on an https URL verifies the chain, so an internal CA fails with an x509 error until the probe host trusts it: install the CA into the host trust store, or point SSL_CERT_FILE at it in the probe's environment file. There is deliberately no verify-off toggle.
When the probe itself goes dark
A probe that dies must never leave its monitors frozen green. Probe-offline alerting is on by default for every probe: if it stops checking in past its threshold (five minutes unless you change it, per probe, in the panel), Pulse opens an alert through your normal channels and the panel marks that probe's monitors as stale instead of showing stale results as current. When it checks in again you get the recovery notice.
One honest limit: a cloud service cannot page you about a LAN fault while that site's WAN is down; that outage is exactly what the probe-offline alert reports, from our side, and push to a phone on cellular still lands. Across brief WAN blips the probe buffers results in memory and ships them when the connection returns, so short outages do not punch holes in your history.
Internal targets on public status pages
A probe-bound monitor can appear on a public status page. Private addresses are masked there: visitors see the monitor's name, type and status, never 10.x addresses or internal hostnames. Your own authenticated views keep the full target.
Troubleshooting: ping inside the LAN
The systemd unit runs the probe unprivileged but grants it CAP_NET_RAW, which is all ICMP needs. If LAN pings fail while tcp checks work, the probe is most likely running under an older unit written before that capability existed: re-run the install command. The unit is rewritten on every run, so the capability is picked up and the service restarts with it.
curl -fsSL https://pulse.corehost.io/install-probe.sh | \ sudo PULSE_PROBE_TOKEN=plsp_live_xxx bash
A few hosts refuse raw ICMP sockets even with the capability: containers started without NET_RAW, and kernels that strip capabilities. There the probe falls back to unprivileged datagram ICMP, which works only when the service's gid sits inside net.ipv4.ping_group_range. Add PULSE_PING_GROUP_RANGE=1 to the install command and the installer writes and applies that drop-in for you. Reach for it only when re-running the installer did not fix the pings, since it widens the range for every gid on the host; remove /etc/sysctl.d/99-pulse-probe-ping.conf and run sysctl --system to revert.
Heartbeats
Your job checks in on a schedule. If it goes silent past the grace window, we alert you.
Use a heartbeat for anything that runs on a timer and has no public endpoint to poll: a nightly backup, a cron job, a CI step, a scheduled task. Create a heartbeat monitor, then have the job POST to its URL each time it finishes successfully.
0 3 * * * /usr/local/bin/backup && \ curl -fsS -m 10 https://api.pulse.corehost.io/v1/heartbeat/<token>
The token is generated when you create the monitor, and the full URL is on the monitor's page with a copy button. It is shown to you once, because listing a monitor redacts it: if you lose it, delete the heartbeat and create it again for a new one. Any HTTP request to that URL counts as a check-in, so curl -fsS at the end of the job is the whole integration.
| Field | Default | Meaning |
|---|---|---|
| period_s | 3600 | How often the job is expected to check in. |
| grace_s | 300 | How long we wait after a missed check-in before alerting. Shown as "Grace window". |
If a check-in does not arrive within period_s + grace_s, the monitor goes down and you get alerted. Heartbeats are inbound to us, so there is nothing to allowlist, and they carry no regions.
Alert channels
Alerts fire on confirmed state changes, not single blips: a failing check is re-checked from other regions and only pages you when regions agree. Pulse ships 17 channel types: the 16 below, plus push to your own devices, covered under phone and push. Add a channel, then send a test alert through it before you rely on it. Secrets you store are redacted everywhere they are read back.
| Channel | Config fields | Where to get it |
|---|---|---|
| Discorddiscord | url | Server Settings, Integrations, Webhooks, New Webhook, copy the URL. |
| Slackslack | url | A Slack app with Incoming Webhooks enabled, Add New Webhook to Workspace, copy the URL. |
| Telegramtelegram | bot_token, chat_id | Create a bot with @BotFather for the token; get chat_id from @userinfobot or the bot getUpdates response. |
| Microsoft Teamsteams | url | Teams channel, Workflows or Connectors, Incoming Webhook, copy the URL. |
| Google Chatgoogle_chat | url | Chat space, Apps and integrations, Webhooks, add one, copy the URL. |
| Matrixmatrix | homeserver, room_id, access_token | Your Matrix client (Element: Settings, Help and About, Access Token) and the room internal ID. |
| Mattermostmattermost | url | Integrations, Incoming Webhooks, Add, copy the URL. |
| Rocket.Chatrocketchat | url | Administration, Integrations, New, Incoming, copy the webhook URL. |
| ntfyntfy | topic, server?, token?, priority? | Pick a topic name on ntfy.sh or your own server; token only for protected topics. |
| Gotifygotify | server, token, priority? | Your Gotify server, Apps, create an application, copy its token. |
| Pushoverpushover | token, user, priority?, device? | Create an application at pushover.net for the API token; your user key is on the dashboard. |
| PagerDutypagerduty | integration_key, severity? | Service, Integrations, add an Events API v2 integration, copy the Integration Key. |
| Opsgenieopsgenie | api_key, region? | Teams, Integrations, API, copy the key. Set region eu for EU accounts. |
| Signalsignal | url, number, recipients | A self-hosted signal-cli-rest-api base URL, the sender number, and recipient numbers. |
| Emailemail | to | Just an email address. Delivered by SES from notify.corehost.io. |
| Webhookwebhook | url, method?, headers?, template? | Your own HTTPS endpoint. Optional JSON template with {{name}} {{status}} {{region}} placeholders. |
Create a channel and fire a test:
POST https://api.pulse.corehost.io/v1/channels
{"name":"ops-discord","type":"discord",
"config":{"url":"https://discord.com/api/webhooks/..."}}
POST https://api.pulse.corehost.io/v1/channels/ops-discord/test # one synthetic alertA test returns {"ok":true,"status":204} on delivery, or {"ok":false,"error":"...","status":401} if the provider rejected it, so you learn about a bad token in the panel, not at 3am. A disabled channel is skipped by the notifier without being deleted.
Alert intelligence
The goal is not more alerts. It is exactly the alerts that matter, once each, to whoever is on call.
Every notification passes through the same discipline, so a flapping check, a planned maintenance, or an incident you already know about does not page you again.
The alert threshold: confirm_checks
A new status must hold for confirm_checks consecutive checks before the monitor flips and an alert fires. A single bad sample never pages you, and the same monitor-and-status alert will not re-emit within a five-minute cooldown. Raise the threshold on a monitor that rides a jittery path; drop it to 1 on one where the first failed check is already news.
| Field | Default | Range | Meaning |
|---|---|---|---|
| confirm_checks | 3 (2 at intervals of 30s or faster) | 1..10 | Consecutive checks a new status must hold before the flip commits and the alert fires. |
| renotify_interval_s | 0 (off) | 0, or 300+ | While a down alert stays open, unacknowledged and unmuted, re-send it on this cadence. Values 1 to 299 are raised to 300. |
POST https://api.pulse.corehost.io/v1/monitors
{"name":"corehost-web","type":"http","url":"https://corehost.io",
"confirm_checks":5, # alert on the 5th consecutive failure
"renotify_interval_s":900} # then re-send every 15 minutes until acked or recoveredBoth fields are per monitor: set them when you create or edit a monitor in the panel, in monitors-as-code YAML, or straight through the API. Monitors on a 30-second or faster interval confirm within at most 2 checks, so a fast monitor also pages fast. Recovery works the same way in reverse: an up status must also hold for the streak before the alert closes, so a monitor flapping across the boundary resolves once, not twenty times. The same streak also gates latency-threshold (degraded) flips, so one number tunes the sensitivity of both.
Maintenance mute
Schedule a maintenance window on a status page (see status pages) and any monitor it covers is muted for the duration: the timeline still records what actually happened, but no notification goes out. The window is derived from its start and end, so it mutes and un-mutes on its own.
Acknowledge and re-notify
When an alert is open you can acknowledge it, which stops the re-notify cadence for that incident and records who did it and when.
POST https://api.pulse.corehost.io/v1/monitors/corehost-web/ack
Authorization: Bearer <id_token>
{"note":"looking into it"} # POST .../unack reverses itRe-notify is the renotify_interval_s cadence from the table above: while a down alert stays open, unacknowledged and unmuted, it re-sends so an incident is not forgotten. Acknowledging stops the cadence, and the acknowledgement clears automatically when the alert closes, so the next incident notifies from scratch.
SLOs and error budgets
Give a monitor an slo_target (say 99.9) over an slo_window_days window and Pulse tracks the attained uptime and the error budget you have left, off the real daily uptime series (days with no data are excluded, never counted as up or down).
POST https://api.pulse.corehost.io/v1/monitors
{"name":"corehost-web","type":"http","url":"https://corehost.io",
"slo_target":99.9,"slo_window_days":30}The summary echoes attained_pct, budget_remaining_pct (100 = full budget, 0 = exhausted, negative = over) and budget_burn. Every value is null when the window has no data. The same numbers ride the Prometheus feed as pulse_slo_error_budget_ratio, so an error-budget-burn alert lives right next to your other Prometheus rules.
Status pages and your own domain
Publish a public status page built from your own monitors, at pulse.corehost.io/s/<slug> or on a domain you own. The public page only ever shows the monitors you list, by bare hostname, with per-region latency and the same honest rollup as our own status page. It never leaks heartbeat tokens or internal URLs.
POST https://api.pulse.corehost.io/v1/status-pages
{"slug":"acme","title":"Acme Status",
"monitors":["corehost-web","api-gw"],
"theme":"auto","custom_domain":"status.acme.com"}Bring your own domain
To serve the page on status.acme.com, add two DNS records at your registrar, then verify:
| Type | Name | Value | Purpose |
|---|---|---|---|
| CNAME | status.acme.com | pages.pulse.corehost.io | Routes traffic to Pulse |
| TXT | _pulse-verify.status.acme.com | pulseverify=<token> | Proves you control the domain |
POST https://api.pulse.corehost.io/v1/status-pages/acme/verify-domain # -> {"domain_verified":true} # or {"domain_verified":false,"reason":"CNAME not found, expected pages.pulse.corehost.io"}
Once the domain is verified there is nothing more to configure. The CNAME lands on the Pulse edge, which obtains a publicly trusted certificate for your exact hostname on the first HTTPS visit and renews it automatically from then on. That very first request can take a few seconds while issuance completes; after that the certificate is cached and https://status.acme.com serves your status page like any other HTTPS site. No certificate upload, no renewal calendar.
Incidents
When something breaks, post an incident and keep it updated. The timeline is append-only, so the history of what you said and when is preserved. A resolved incident keeps showing for 24 hours, then moves to the feed and the paginated history.
POST https://api.pulse.corehost.io/v1/status-pages/acme/incidents
{"title":"API latency elevated","status":"investigating",
"impact":"minor","affected":["api-gw"],
"body":"We are looking into elevated latency on the API."}
POST https://api.pulse.corehost.io/v1/status-pages/acme/incidents/inc_.../updates
{"status":"resolved","body":"Latency is back to baseline."}Status moves through investigating, identified, monitoring, resolved; impact is one of none, minor, major, critical. Each update notifies confirmed subscribers.
Scheduled maintenance
Announce planned work ahead of time. A maintenance window carries a start and end, the monitors it covers, and derives its own state (scheduled, in progress, completed) from the clock. While it is in progress it also mutes alerts on the monitors it names, so planned work never pages you.
POST https://api.pulse.corehost.io/v1/status-pages/acme/maintenance
{"title":"DB upgrade","message":"Brief write pause.",
"monitors":["api-gw"],
"window":{"start":"2026-08-21T02:00:00Z","end":"2026-08-21T03:00:00Z"}}Subscribers
Visitors can subscribe to a page by email or webhook and get told when an incident updates or maintenance begins. Subscriptions are double opt-in: a subscribe request always returns the same pending shape (so the endpoint is not a way to probe who is subscribed), and a confirmation link activates it. The confirm token doubles as the one-click unsubscribe token.
POST https://api.pulse.corehost.io/v1/public/status-page/acme/subscribe
{"kind":"email","target":"ops@acme.com"} # -> {"ok":true,"pending":true}Email subscribers are delivered by SES from notify.corehost.io; webhook subscribers always receive a signed POST. As the page owner you can list and remove subscribers from the panel.
90-day uptime bars
Every published monitor shows a 90-day bar strip built from the real daily uptime series. A day with no data is a grey bar, not a fake 100 percent, and it is excluded from the headline uptime figure, so twelve days of real data reports honest twelve-day uptime rather than pretending to a full quarter.
Status badges
Embed a live badge for any monitor that appears on one of your published pages. The badge is a hand-built SVG (no external shields dependency), served with a short cache and an accessible title.
<img src="https://api.pulse.corehost.io/v1/public/badge/<account_id>/corehost-web.svg"
alt="corehost-web status">
# ?metric=uptime shows the 30-day uptime %, ?style=flat-square, ?label=APIA badge renders only for a monitor listed on a published page; anything else returns a grey unknown badge so an embedded image degrades to a readable pill rather than a broken image. No latency numbers or other tenant data are ever leaked through it.
RSS / Atom feed
Every status page publishes an Atom 1.0 feed of its incidents and maintenance, newest first, so readers and dashboards can subscribe without polling the page.
GET https://api.pulse.corehost.io/v1/public/status-page/acme/feed.xml
# Content-Type: application/atom+xml. One entry per incident, full update timeline, fully escaped.Looking glass
A public multi-tool looking glass, running for real on our network. Nothing here is simulated.
Point any Pulse node at a public host and it runs the actual tool and reports exactly what it saw. Four tools share one job queue and one polling shape. Try it on the looking glass page, or drive it from the API.
| Tool | Reports |
|---|---|
| ping | packets sent / received, loss %, rtt min / avg / max / mdev, the resolved IP |
| mtr | every hop with per-hop loss and the rtt samples, plus the resolved IP |
| dig | the answer and authority records, TTLs, the responding server, query time, rcode |
| http | status, final URL, the full DNS / connect / TLS / TTFB timeline, redirects, a small header allowlist, and the TLS details |
POST https://api.pulse.corehost.io/v1/looking-glass
{"tool":"ping","target":"example.com","region":"ams"} # -> {"job_id":"trc_...","status":"pending"}
GET https://api.pulse.corehost.io/v1/looking-glass/trc_... # poll every 2s until done or failedThe target is validated and SSRF-gated exactly like a monitor: a hostname or literal IP for ping / mtr / dig (with an optional record_type), a full URL for http; anything resolving to a private, loopback or reserved address is refused. Requests are rate limited per source IP. A failed run reports a real error, never a fabricated hop or timing.
Sharing a result
Every finished run has a Share button that produces a public link, pulse.corehost.io/lg/<job_id>. Anyone with the link sees the real result: the tool, target, region, output and time, and a way to run their own. It never shows your IP address. The link stays live for one hour after the run, then it expires with the stored result.
Prometheus and Grafana
Scrape your monitors straight into Prometheus. The feed is authed by a scoped API token (see API tokens) with the metrics:read scope, presented as a bearer token or, for scrapers without bearer support, as a ?token= query parameter.
# prometheus.yml
scrape_configs:
- job_name: pulse
scheme: https
metrics_path: /v1/metrics
authorization:
credentials: plssk_live_xxxxxxxx # a metrics:read token
static_configs:
- targets: ['api.pulse.corehost.io']The feed is real, from live monitor state and rollups; a metric with no data for a monitor is omitted rather than emitted as a fake zero. The families:
| Metric | Labels | Meaning |
|---|---|---|
| pulse_up | - | 1 if the scrape succeeded. |
| pulse_monitor_up | monitor, type | Monitor liveness (1 up, 0 down). |
| pulse_monitor_region_up | monitor, region | Per-region liveness in the last window. |
| pulse_monitor_latency_ms | monitor, region, quantile | Latency by quantile (0.5 / 0.95 / 0.99). |
| pulse_monitor_latency_avg_ms | monitor, region | Mean latency over the window. |
| pulse_monitor_jitter_ms | monitor, region | Latency standard deviation. |
| pulse_monitor_checks_total | monitor, region | Checks recorded (counter). |
| pulse_monitor_failures_total | monitor, region | Failed checks (counter). |
| pulse_monitor_uptime_ratio | monitor, window | Uptime 0..1 over 24h and 30d. |
| pulse_cert_days_remaining | monitor | Days until the leaf certificate expires. |
| pulse_cert_valid | monitor | 1 while the certificate still works for visitors. 0 only for expired or invalid, so an alert rule on == 0 is an outage signal. |
| pulse_cert_expiring | monitor | 1 inside the monitor's cert_warn_days window, while the certificate is still valid. |
| pulse_slo_target_ratio | monitor | Configured SLO target 0..1. |
| pulse_slo_error_budget_ratio | monitor | Remaining error budget 0..1 (negative = over). |
If the request Accept header asks for application/openmetrics-text the feed is served as OpenMetrics 1.0.0 instead. The label set is stable and low cardinality, so rate(), histogram_quantile-free percentiles, and per-region joins all work cleanly.
The Grafana dashboard
A ready-made dashboard is in the panel, under Integrations: pick Grafana and Download JSON. Import it, pick your Prometheus data source, and you get overview stats, latency percentiles, per-region availability, failure rate, uptime ratio, SLO error budget and a certificate table, all filtered by monitor and region template variables. Every panel query uses only the metric and label names above.
# Pulse -> Integrations -> Grafana -> Download JSON # Grafana -> Dashboards -> New -> Import -> Upload JSON file pulse-grafana-dashboard.json
API tokens
Scoped tokens let a script, a scraper or a teammate do exactly one job and no more. They are separate from your Cognito login: a token carries a subset of scopes, an optional expiry, and can be revoked on its own without touching your account.
POST https://api.pulse.corehost.io/v1/tokens
Authorization: Bearer <id_token>
{"name":"grafana-scrape","scopes":["metrics:read"],"expires_days":365}
# -> {"token":"plssk_live_a1b2...","prefix":"plssk_live_a1b2", ...}POST /v1/tokens is the only time you see it; only its hash is stored. Copy it then. List and delete show the prefix and metadata, never the secret. | Scope | Grants |
|---|---|
| metrics:read | Read the Prometheus feed. |
| monitors:read | Read monitors, their status, rollups and percentiles. |
| monitors:write | Create, update, acknowledge and delete monitors. |
| channels:read | List alert channels. |
| channels:write | Create, test, disable and delete alert channels. |
| probes:read | List private probes and their status. |
| probes:write | Create and delete private probes. |
Where you present it matters. The panel's own routes under /v1/... take the signed-in session token and nothing else. An API token is used on /v1/metrics for the Prometheus feed, and on the token plane at /v1/cli/<path> for the rest, which serves the same handlers as the panel for an enumerated subset of routes. This is what pulsectl talks to.
curl -fsS https://api.pulse.corehost.io/v1/cli/summary \ -H "Authorization: Bearer plssk_live_..." # same reply as GET /v1/summary in the panel # the same token on /v1/summary is refused: that path wants the session token
A token is presented as Authorization: Bearer plssk_live_... (or ?token= for the metrics feed). A path the token plane does not serve answers 404 there even though the panel reaches it, because a token gets a deliberately small surface rather than everything a browser session can do. Scopes are enforced per request, so a token that holds only metrics:read is refused everywhere else, and a scraper token can never delete a monitor. Give a token the smallest set that does its job: a Grafana scrape wants metrics:read alone. Name it so future-you knows what it is for; names are unique per account.
This is also what the CLI holds. pulsectl login mints a token for that machine with the scopes its own commands need, deliberately without metrics:read, so a leaked CLI credential is not also a metrics scraper and a Grafana token is not also a monitor-delete credential. Both kinds are listed together under Settings, API tokens, and revoking one is immediate.
Uptime Kuma import
Moving from Uptime Kuma? Export your Kuma backup and post it. Each Kuma monitor is mapped to a Pulse monitor and created, idempotently by name, so re-running the import never duplicates.
POST https://api.pulse.corehost.io/v1/import/uptime-kuma Authorization: Bearer <id_token> # the signed-in session token <your Kuma Export JSON> # -> {"created":["corehost-web"],"updated":[],"skipped":[{"source":"My Steam Server","reason":"..."}]}
| Kuma type | Becomes |
|---|---|
| http / keyword | http, keeping the URL and keyword |
| port / tcp | tcp on the host and port |
| ping | ping on the hostname |
| dns | dns with the record type |
| push | heartbeat, with a fresh Pulse token (Kuma push tokens are not reused) |
Names are slugified and de-duplicated, intervals are clamped to the plan floor, and accepted status codes and retry settings carry over (Kuma retries map to confirm_checks). Kuma types Pulse does not run (steam, mqtt, radius, docker, grpc, real-browser) are returned in skipped with an honest reason, never silently dropped or faked into a different kind of check. Notification, tag and proxy objects are ignored.
API reference
The <id_token> in these examples is the session token your browser holds after signing in. It is short lived and refreshes itself, which makes it the wrong thing to paste into a script: for anything unattended, create an API token or let the CLI hold one for you.
JWT routes are account scoped and require Authorization: Bearer <id_token>. Scoped-token routes take Authorization: Bearer plssk_live_... and check the token holds the right scope. Public routes need no auth. Probe routes authenticate with x-probe-token.
Public
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/public/fleet | public | Every Pulse node with IPv4 / IPv6, online status, live latency |
| GET | /ips.txt | public | Every node address as plain text, one IP per line, v4 then v6, for firewall automation (also at /v1/public/ips.txt) |
| GET | /v1/public/status | public | Public status of the monitors Corehost runs on Pulse |
| GET | /v1/public/status-page/{slug} | public | A published status page, plus incidents, maintenance and 90-day uptime |
| GET | /v1/public/status-page/{slug}/incidents | public | Paginated resolved-incident history |
| GET | /v1/public/status-page/{slug}/feed.xml | public | Atom feed of incidents and maintenance |
| GET | /v1/public/badge/{account_id}/{monitor}.svg | public | Live SVG status or uptime badge for a published monitor |
| POST | /v1/public/status-page/{slug}/subscribe | public | Subscribe by email or webhook (double opt-in) |
| GET | /v1/public/status-page/{slug}/subscribe/confirm | public | Confirm a subscription by token |
| POST | /v1/public/status-page/{slug}/unsubscribe | public | Unsubscribe by token |
| POST | /v1/looking-glass | public | Run ping / mtr / dig / http from a Pulse node |
| GET | /v1/looking-glass/{job_id} | public | Poll a looking-glass job until it completes |
| POST | /v1/tools/trace | public | Run a traceroute / MTR from a Pulse node to a public host |
| GET | /v1/tools/trace/{job_id} | public | Poll a trace job until it completes |
| POST | /v1/heartbeat/{token} | public | Ping a heartbeat monitor |
Monitors
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/summary | jwt | Your monitors with status, regions, TLS, latency, SLO and alert state |
| GET | /v1/monitors | jwt | List monitors |
| POST | /v1/monitors | jwt | Create or update a monitor: http / tcp / ping / dns / tls_cert / heartbeat, plus regions, ip_version, confirm_checks, renotify_interval_s, cert_warn_days, slo_target, content assertions, dns_record_type / dns_expected, latency_threshold_ms |
| GET | /v1/monitors/{name}/rollups | jwt | Per-region minute rollups with percentiles, jitter and v4/v6 split |
| GET | /v1/monitors/{name}/percentiles | jwt | Bucketed pooled p50 / p95 / p99 over a window |
| POST | /v1/monitors/{name}/ack | jwt | Acknowledge the open alert |
| POST | /v1/monitors/{name}/unack | jwt | Reverse an acknowledgement |
| DELETE | /v1/monitors/{name} | jwt | Delete a monitor |
Metrics and tokens
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/metrics | token | Prometheus / OpenMetrics feed (metrics:read scope) |
| ANY | /v1/cli/{path} | token | Token plane: the enumerated monitor, channel and probe routes an API token may reach, served by the same handlers as the panel path it mirrors |
| GET | /v1/tokens | jwt | List scoped API tokens (prefix and metadata only) |
| POST | /v1/tokens | jwt | Create a scoped token; the raw token is returned once |
| DELETE | /v1/tokens/{token_id} | jwt | Revoke a token |
| POST | /v1/import/uptime-kuma | jwt | Import an Uptime Kuma export |
Alert channels
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/channels | jwt | List channels, secrets redacted |
| POST | /v1/channels | jwt | Create a channel from the 17-type registry |
| POST | /v1/channels/{name}/test | jwt | Send one synthetic alert through the channel |
| PUT | /v1/channels/{name} | jwt | Enable or disable a channel without deleting it |
| DELETE | /v1/channels/{name} | jwt | Delete a channel |
| GET | /v1/push/devices | jwt | List devices registered for push |
| POST | /v1/push/devices | jwt | Register this device for push; ensures the push channel exists |
| DELETE | /v1/push/devices/{device_id} | jwt | Unregister a push device |
Private probes
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/probes | jwt | List your private probes |
| POST | /v1/probes | jwt | Create a probe; returns token once plus install commands |
| GET | /v1/probes/{probe_id} | jwt | Live status of one probe, for the connect poller |
| DELETE | /v1/probes/{probe_id} | jwt | Remove a probe (refused while monitors are bound to it) |
| GET | /v1/probe/config | probe token | A probe pulls its monitors, with ip_version and cert_check |
| POST | /v1/probe/results | probe token | A probe ships check results with ip_version, ip and cert |
| GET | /v1/probe/tasks | probe token | A Pulse node pulls looking-glass jobs (ping / mtr / dig / http) |
| POST | /v1/probe/task-results | probe token | A Pulse node posts tool output back |
Status pages
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/status-pages | jwt | List your status pages |
| POST | /v1/status-pages | jwt | Create a status page |
| GET | /v1/status-pages/{slug} | jwt | Read one of your status pages |
| PUT | /v1/status-pages/{slug} | jwt | Update a status page; slug is immutable |
| DELETE | /v1/status-pages/{slug} | jwt | Delete a status page |
| POST | /v1/status-pages/{slug}/verify-domain | jwt | Verify the custom domain DNS records |
Incidents, maintenance and subscribers
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /v1/status-pages/{slug}/incidents | jwt | List incidents on your page |
| POST | /v1/status-pages/{slug}/incidents | jwt | Open an incident |
| POST | /v1/status-pages/{slug}/incidents/{id}/updates | jwt | Append an update to the timeline |
| PUT | /v1/status-pages/{slug}/incidents/{id} | jwt | Edit incident metadata |
| DELETE | /v1/status-pages/{slug}/incidents/{id} | jwt | Delete an incident |
| GET | /v1/status-pages/{slug}/maintenance | jwt | List maintenance windows |
| POST | /v1/status-pages/{slug}/maintenance | jwt | Schedule maintenance (mutes covered monitors) |
| PUT | /v1/status-pages/{slug}/maintenance/{id} | jwt | Update a maintenance window |
| DELETE | /v1/status-pages/{slug}/maintenance/{id} | jwt | Delete a maintenance window |
| GET | /v1/status-pages/{slug}/subscribers | jwt | List subscribers to your page |
| DELETE | /v1/status-pages/{slug}/subscribers/{id} | jwt | Remove a subscriber |
Support
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v1/tickets | jwt | Support tickets (action: create / list / get / reply / close) |
The network and allowlisting
Checks originate from our published probe nodes. If your WAF or rate limiter is strict, allowlist these addresses so your monitors stay clean. Every node answers ping, traceroute, and a fast /ping over HTTPS so you can verify reachability from your side. The live list, with per-region latency and an MTR tool, is on the network page.
194.31.140.8 # ams · Amsterdam 185.158.138.86 # ash · Ashburn 2.56.166.230 # lax · Los Angeles 45.139.227.22 # lon · London 45.139.226.195 # sin · Singapore 85.202.161.4 # sto · Stockholm 2a04:ff00:860:9::a # ams · Amsterdam 2a04:ff00:820:18::a # lax · Los Angeles 2001:df7:1d81:2c::a # lon · London 2a04:ff00:800:c3::a # sin · Singapore
Automate your allowlist
Two public endpoints, no auth, so a script can do the pasting. For firewall tooling that wants bare addresses, /ips.txt is the whole network as plain text, one IP per line, all IPv4 first, then IPv6:
$ curl -fsS https://api.pulse.corehost.io/ips.txt
194.31.140.8
185.158.138.86
2.56.166.230
45.139.227.22
45.139.226.195
85.202.161.4
2a04:ff00:860:9::a
2a04:ff00:820:18::a
2001:df7:1d81:2c::a
2a04:ff00:800:c3::a
# all IPv4 first, then IPv6; nodes without a confirmed IPv6 allocation contribute no v6 line For everything else, GET /v1/public/fleet is the same list as JSON with the region code, city, country, hostname, both address families and live online status per node:
$ curl -fsS https://api.pulse.corehost.io/v1/public/fleet
{"fleet":[
{"code":"ams","city":"Amsterdam","country":"NL",
"region_label":"eu-west","hostname":"ams.probe.corehost.io",
"ipv4":"194.31.140.8","ipv6":"2a04:ff00:860:9::a","online":true, ...},
...]}Both endpoints read the same source of truth, so they can never disagree with each other or with the table on the network page. /ips.txt is cached for five minutes; re-fetch it from cron and your allowlist tracks node changes on its own. /v1/public/ips.txt serves the identical text if you prefer every API path under /v1.
CLI
pulsectl is one Python file (3.9 or newer, standard library only, no pip install). Download it, sign in once, then manage everything from the terminal. It is a full way in, equal to the panel and the API, and the only one that applies a monitors YAML from a repo or a CI job.
curl -fsSL https://pulse.corehost.io/pulsectl -o ~/.local/bin/pulsectl chmod +x ~/.local/bin/pulsectl
Nothing to configure: the API base and the sign-in client id come from the panel's public config. Credentials are cached in ~/.config/pulse/credentials at mode 600. Point it somewhere else with PULSE_API_URL if you need to.
Signing in
One command, however you created the account:
pulsectl login # browser sign-in: Google, GitHub or passwordIt opens your browser at the same sign-in page the panel uses, so Continue with Google, Continue with GitHub and email-and-password all work there exactly as they do in the panel. When you finish, the browser hands the CLI an API token and prints the account it signed in as. There is no password prompt in the terminal, and none is needed: an account created with Google or GitHub has no password at all, so a CLI that insisted on one could never work for it.
Under the covers it is the OAuth code flow with PKCE, redirecting to a callback on localhost that only your own machine can reach. What lands in ~/.config/pulse/credentials, mode 600, is a scoped API token named after the host and the date, so Settings, API tokens tells you which machine holds which token.
For the cases where a browser is not the answer:
| Situation | Command |
|---|---|
| A server over SSH, a container, CI: no browser on the machine. | pulsectl login --with-token, then paste a token you created under Settings, API tokens. In CI, set PULSE_API_TOKEN instead and nothing is written to disk at all. |
| A browser is there, but you would rather open the URL yourself. | pulsectl login --no-browser prints the URL instead of launching anything. Open it on that same machine, since the callback is local to it. |
| An email-and-password account, scripted. | pulsectl login --password keeps the old terminal prompt, and PULSE_EMAIL with PULSE_PASSWORD keeps working unattended. Accounts without a password cannot use this, which is the whole point of the browser flow. |
pulsectl whoami says which account and token this machine is using. pulsectl logout revokes that token at the API before deleting the local copy, so a stray backup of the file is worth nothing, and Settings, API tokens revokes it from the panel just as well. Revoking a CLI token never touches your sign-in: it is a separate credential that the CLI minted for itself.
Commands
pulsectl apply pulse.yaml # monitors-as-code upsert pulsectl apply pulse.yaml --dry-run # show the plan, send nothing pulsectl apply pulse.yaml --prune # also delete what the file no longer has pulsectl export pulse.yaml # write your existing monitors to a file pulsectl ls # live status table pulsectl ls --regions # per-region latency columns pulsectl rollups <monitor> # per-region minute rollups pulsectl probes add <label> # create a private probe, token shown once pulsectl channels add discord ops <webhook> pulsectl channels test ops # send one synthetic alert
Phone and push
Pulse works on a phone. The panel is fully responsive down to a 360px screen: tables fold into readable cards, forms go single-column, modals become full-screen sheets, and every tap target is at least 44px. Nothing clips, nothing scrolls sideways.
Add it to your home screen
Pulse is an installable Progressive Web App. There is no app store download. Open pulse.corehost.io on your phone and choose Add to Home Screen (Share menu on iOS Safari, the install prompt or the menu on Android Chrome). It launches full screen with its own icon, keeps a light offline shell so it opens without a connection, and respects the notch and home indicator.
Push notifications to your pocket
Add the Push channel and an alert reaches every device you turned it on in, as a native Web Push notification. On iOS that means the home-screen install; on Android and desktop any supported browser works. It rides the same alert intelligence as every other channel, so a down alert is confirmed across regions before it fires, and it is deduplicated with your other channels.
Turn push on for the device
In the panel, open Alerting, find the push channel and choose Devices. Your browser asks permission once; granting it registers that device. Repeat on each device you want alerted.
Route a monitor to it
Attach the push channel to a monitor's alert rule like any other channel, then send a test alert to prove it before you rely on it.
Tap the alert
A push shows the monitor and its new status. Tapping it opens Pulse straight to that monitor. A
downalert stays on screen until you act on it; recovery alerts clear on their own.
Privacy
Pulse collects what it needs to run the monitoring you asked for and nothing to profit from you. No advertising, no ad trackers, no cross-site analytics, and we never sell or share your personal data. We store your account email, the monitors and channels you configure, the check results our probes measure, and, if you turn it on, an opaque push token. We do not read the content of the sites you monitor beyond the assertion you set.
Secrets are encrypted and shown redacted; API tokens are stored only as SHA-256 hashes; data is encrypted in transit and at rest. There is no one-click export or delete in the panel yet: email support@corehost.io from the address on the account and we send you a copy or remove the account and its history.
Full detail is on the Privacy Policy and Terms of Service pages.
Troubleshooting and FAQ
- Do I have to install anything to monitor my website?
- No. Create the monitor in the panel and our network starts checking it from every region within one interval. You install a private probe only to check something the internet cannot reach, such as a device on your own LAN, and the CLI only if you would rather work from a terminal.
- Where do I put my monitors? Is there a config file to write?
- Write one if you want to.
pulsectl apply pulse.yamlkeeps your monitors in a YAML file in your own repository, reviewed and applied from CI like the rest of your infrastructure, and applying is idempotent so re-running never duplicates anything. The panel and the API do the same job if you would rather not. Whichever you use, the monitors themselves are stored by Pulse, and our nodes and any probe you install are told what to check by asking Pulse for it over HTTPS. The one place a file never goes is on the probe host. See monitors as code and where monitors live. - My probe logged something about
monitors.yaml. - Do not create that file. It belongs to the local development mode of the open-source binary, which the hosted product has no concept of, and a probe of the current version never mentions it: without a token it says PULSE_PROBE_TOKEN is not set and stops. Either way the cause is the same, a probe that started without its token, usually the binary run by hand rather than through the service. Re-run the install command with
PULSE_PROBE_TOKENset to a token from the Probes page; that also brings the binary up to date. - My probe shows Online but nothing is being checked.
- Nothing is bound to it yet. A probe checks only the monitors you point at it: open a monitor, go to the step that asks where checks run from, and pick your probe under Your probes. The probe's log line reads
0 monitorsuntil then, which is the healthy state for a probe with no work, and the count follows within 30 seconds of you binding one. - I signed up with Google or GitHub, and the CLI asks for a password.
- An account created with Google or GitHub has no password at all, so no amount of resetting will produce one. Run
pulsectl login: it opens your browser at the same sign-in page the panel uses, you continue with Google or GitHub as usual, and the CLI saves a scoped API token for that machine. On a box with no browser, create a token under Settings, API tokens and runpulsectl login --with-token. Accounts that do have a password can still usepulsectl login --password. See CLI. - My monitor is down but the site loads for me.
- Check the per-region breakdown on the monitor. A single slow or failing region against many healthy ones is usually a regional network issue, not your site. Pulse confirms across regions before alerting for exactly this reason.
- The site went down but the alert arrived a few checks later.
- That is
confirm_checksdoing its job: a new status must hold for the configured streak (default 3 checks, 2 on fast intervals) before the flip commits and the alert fires, so one bad sample never pages you. Set it to 1 on a monitor where the first failure is already news. See alert intelligence. - A traceroute hop shows
???. - That router simply does not answer ICMP. It is normal. Loss at the final hop is what matters, not a silent middle hop.
- My WAF is blocking the checks.
- Allowlist the addresses above, or automate it:
GET /ips.txtis every node as plain text, one IP per line, andGET /v1/public/fleetis the same list as JSON. Every node also serves/pingover HTTPS so you can confirm reachability. See the network and allowlisting. - I got a degraded alert but the monitor never went down.
- That is the latency threshold working as designed: every check succeeded, but a confirmed streak of them ran slower than your
latency_threshold_ms. The alert names the regions that breached and the worst time observed. Uptime and SLO are untouched, because nothing was down. If it fires too often, raise the threshold above the monitor's routine p99 or increaseconfirm_checks. - A probe will not come online.
- Work through it in this order: the service is running (
systemctl is-active pulse-probe); the box has outbound HTTPS to the Pulse API endpoint, which is the API base and not a website URL; and/etc/pulse-probe.envholds the token exactly as issued. The log tells you which of those it is:journalctl -u pulse-probe -n 20shows either theprobe mode:check-in line or the error that stopped it. On Windows the service logs to the Application event log, and in Docker usedocker logs pulse-probe. The connect screen keeps the install commands visible while it waits so you can re-check. - Ping checks from my probe fail but tcp checks work.
- The unit the installer writes grants the service
CAP_NET_RAW, so ICMP works unprivileged. Failing pings usually mean an older unit from before that capability: re-run the install command, which rewrites the unit and restarts the service. Where the capability cannot be granted at all, such as a container started withoutNET_RAW, install withPULSE_PING_GROUP_RANGE=1for the unprivileged-ICMP fallback. See internal network monitoring. - Why show p95 and p99 instead of just an average?
- An average hides the tail, and the tail is where your users feel pain. Pulse keeps the real round-trip samples per region and computes true percentiles from them; a bad p99 against a fine p50 is exactly the signal an average erases. A window with too few samples shows a gap, never a made-up line.
- A percentile or cert date is blank.
- That value has not been measured yet, and Pulse will not invent one. Percentiles, jitter, cert dates, hop counts and IP families all come from the probe; until a real reading lands you see a loading or empty state, never a placeholder number.
- Can I scrape Pulse into my own Prometheus and Grafana?
- Yes. Create a token with the
metrics:readscope, point a scrape at/v1/metrics, and import the Grafana dashboard from Integrations in the panel. Every value is real; a metric with no data for a monitor is omitted rather than emitted as a fake zero. - Is the probe really open source?
- Yes. The probe that ships every check is one small Go binary, outbound only, and published at github.com/RobWhyte91/pulse-probe. You can read exactly how a check is timed and posted, or run it yourself inside your network as a private probe.
- Is it really free for commercial use?
- Yes. Pulse is the front door of the corehost platform, funded by the platform rather than by your monitor count. 100 monitors and 15 second checks, personal or commercial, no card.