Custom HTTP sources

Build a KPI on any JSON API: URL, headers, date tokens, the JSON path syntax, the approval step, the test run and every error message you can get.

When no connector covers the service you want to track, the HTTP source lets you point Pulsaria at a JSON endpoint yourself. You give it a URL, optionally a few request headers, and a path into the response. Pulsaria calls the endpoint, walks the path, and stores the number it finds as today’s value.

This is a GET request against a JSON API and nothing more. If your number needs a POST, a login flow or some text processing, use Shell commands instead.

Creating an HTTP KPI

  1. Dashboard → + KPI in a group header. On an empty dashboard, Create KPI.
  2. Pick the HTTP card — “Any HTTP endpoint with a number in the JSON response. Needs your approval.”
  3. Fill in URL, add Headers rows if the API needs them, and enter the JSONPath.
  4. Press Run test and look at the parsed value.
  5. Press Approve and confirm the dialog.
  6. Optionally set a Cron schedule.
  7. Fill in the Goal section below — name, target, unit, direction, KPI type, period — and press Create KPI.

To change one later, open the KPI’s detail page and press Edit. The same form opens, with the source switchable in place.

Create KPI and Save changes are disabled without a licence or an active trial; the button carries the tooltip “Requires a license or active trial”. Run test is not affected.

All four code fields — URL, header names, header values, JSONPath — clean up macOS text substitution for you. Curly quotes, en dashes and are turned back into their ASCII equivalents when you leave the field, and again every time you test, approve or save. You will see the cleaned value in the field.

The URL

One line, monospaced, required. The placeholder shows the intended shape:

https://api.example.com/metrics?from={{firstDayOfMonth}}&to={{lastDayOfMonth}}

The rules the request runs under, all fixed:

RuleValue
MethodGET, always
Schemeshttp:// and https:// only
Timeout10 seconds
Redirectsat most 5, and a redirect leaving http/https is not followed
Response sizeread up to 64 KB, the rest is cut off
User-Agentpulsaria/<app version>

None of these are configurable. There is no proxy setting, no custom CA and no way to raise the timeout.

Pulsaria sends the request from its native layer, not from the app’s web view. An API that rejects browser origins (CORS) is therefore not a problem — but neither is there a cookie jar, a Keychain lookup or any browser session to piggyback on. Whatever authenticates the request has to be in the URL or in a header.

Pulsaria does not build or escape the query string for you. Write it exactly the way the API expects it, including any %-escapes.

Headers

Optional. The Headers block starts empty with the line “No headers.” Press + Header to add a key/value row, the X on the right to remove one. Placeholders show Authorization and Bearer ….

This is where an API token goes. Two common shapes:

Header nameHeader value
AuthorizationBearer sk_live_abc123…
X-API-Keyabc123…

Which one your API wants is in its documentation — Pulsaria sends whatever you type, verbatim.

Rows whose name is blank are dropped when the KPI is saved, so a half-filled row does no harm. Header names are trimmed; header values are stored exactly as typed, spaces included.

Read Where header values are stored before you paste a token. It does not go into the macOS Keychain.

Date tokens

Under the URL field sits a row of chips labelled Date tokens. Click one and {{tokenName}} is inserted at the cursor position in the URL field. Each chip shows what it resolves to right now, and hovering gives the long description.

Tokens are resolved at fetch time, not when you save. That is the whole point: a URL ending in &from={{firstDayOfMonth}}&to={{lastDayOfMonth}} is approved once and stays approved as the calendar moves on.

TokenResolves to
{{today}}today’s date
{{yesterday}}yesterday’s date
{{last7DaysAgo}}the date 7 days ago
{{last28DaysAgo}}the date 28 days ago
{{last30DaysAgo}}the date 30 days ago
{{firstDayOfMonth}}the 1st of the current month
{{lastDayOfMonth}}the last day of the current month
{{firstDayOfLastMonth}}the 1st of last month
{{lastDayOfLastMonth}}the last day of last month
{{firstDayOfQuarter}}the first day of the current quarter
{{lastDayOfQuarter}}the last day of the current quarter
{{firstDayOfYear}}1 January of the current year
{{lastDayOfYear}}31 December of the current year
{{now}}the current timestamp as ISO 8601, e.g. 2026-07-25T07:00:00.000Z
{{nowUnix}}the current Unix timestamp in seconds

Every entry above {{now}} produces a plain YYYY-MM-DD date built from your Mac’s local calendar day.

Details worth knowing:

  • Tokens work in the URL and in header values. Header names are never substituted.
  • The chips insert into the URL field only. To put a token into a header value, type it by hand.
  • Whitespace inside the braces is tolerated: {{ today }} works.
  • An unknown name is left alone. {{foo}} is sent literally, which keeps a legitimate {{…}} in a URL path intact.
  • There is no arithmetic and no custom format. These 15 tokens are all there is — no {{today-3}}, no DD.MM.YYYY.

The JSONPath

Required, one line, placeholder data.items[0].value. The help text under the field reads “Dot/bracket path in the JSON response. Must resolve to a number.”

This is a small, deliberate path walker, not a JSONPath implementation. What it understands:

PieceExampleMeaning
Dot segmenttotals.visitorsa key inside an object
Numeric indexitems[0]an element of an array
Chaineddata.rows[2][0]any mix of the two
Leading index[0].valuewhen the response itself is an array
Quoted keymetrics["sessions.total"]a key that contains a dot or a bracket; '…' works too

Whitespace inside brackets is ignored, so items[ 0 ] is fine. Bracket content that is not an integer is treated as a key, with or without quotes: data[total] means the same as data.total.

What it does not understand:

  • No wildcards (*), no filters ([?(@.x>1)]), no slices ([0:3]).
  • No leading $. $.totals.visitors looks for a key literally named $ and misses.
  • No recursive descent. A doubled dot is simply ignored, so data..value behaves exactly like data.value — it does not search.
  • No aggregation. You get exactly one element; there is no sum, count or average across an array. (Connectors have that internally, but it is not exposed here.)
  • No negative indexes in practice. [-1] parses, but a JavaScript array has no -1 element, so it never resolves. There is no way to say “the last element”.
  • No key containing a ], because the parser ends the bracket at the first one.

What the response has to look like

Four things have to line up, in this order:

  1. The status is 2xx. Anything else fails with HTTP <status>.
  2. The body parses as JSON. HTML error pages, XML, CSV, JSONL and plain text all fail with “Response is not valid JSON”.
  3. The path resolves to something. A missing key, a missing array element or a null on the way stops the walk.
  4. The value is a number, or a string that reads as one. 4821, "4821", "12.5" and "1e3" all work. "1,284" does not — a thousands separator makes it unreadable. true, null, an object and an array are all rejected.

The number is stored exactly as it arrives. Pulsaria applies no transform, no rounding, no /100 and no currency conversion. If an API reports cents, your KPI is in cents unless you change the endpoint.

Two consequences worth planning for:

  • A response over 64 KB is cut at 64 KB, which almost always breaks the JSON parse. If your endpoint returns a long list, narrow it with query parameters instead.
  • An empty result is an error, not a zero. If the API answers a day with no data with [] or {"results":[]}, the fetch fails and no value is written. Connector KPIs treat that case as 0; custom HTTP KPIs do not.

The test run

Press Run test — the label switches to “Running…”. The test sends the real request, with the date tokens resolved, and shows the result in a panel. It writes nothing: no KPI value, no fetch-log row, no alert.

The panel shows, top to bottom:

  • A header reading Parsed value with Preview: <number> in green, or Test failed in red.
  • URL — the URL as it was actually sent, with the tokens filled in, and a Copy button.
  • Response body — up to 4096 characters. Anything over 400 characters or 6 lines gets a Show more / Show less toggle.
  • Error — the message, when there is one.
  • A meta row: Method (GET), HTTP status and Duration in ms.

Your header values are not echoed in the panel. Editing the URL, a header row or the JSONPath clears the result, so a stale green panel cannot mislead you.

Use the panel the other way round when a path misses: read the Response body section, find the number by eye, and write the path to it.

Approval

An HTTP KPI is a URL that Pulsaria will call on its own, repeatedly. So it does not run until you have said yes to the exact configuration.

While the KPI is unapproved, an amber Approval required banner sits above the URL field: “Auto KPIs only run after you approve them. Run a test below, then click Approve. Without approval the scheduler will skip this KPI.”

Press Approve, next to Run test. A dialog titled Confirm HTTP configuration opens, reading “This URL will be called by Pulsaria on every fetch. After approval, Pulsaria locks the URL, headers and JSONPath against unnoticed changes.” Below it, in monospace, is the URL on the first line and the JSONPath on the second, then the line “Only approve sources you trust. Later edits require re-approval.” Press Approve again to confirm, or Cancel.

The banner then turns green: Approved — “The scheduler will fetch this KPI automatically on the configured cron.”

Two honest notes about that dialog. It shows the URL template, with {{tokens}} unresolved, not the URL that will actually be sent. And it does not list your header rows, even though they are part of what you are approving — check them in the form before you press the button.

What is locked

Pulsaria stores a SHA-256 fingerprint of three things together: the URL, the header rows sorted by name, and the JSONPath. From that follow a few useful behaviours:

  • Reordering header rows does not invalidate the approval. Renaming a header or changing its value does.
  • A row with a blank name is ignored, so adding an empty row changes nothing.
  • Date tokens are part of the template, not of the resolved request — the approval survives every date change.
  • Approve is available even if you never ran a test, or the test failed. The green hint ”✓ Test successful — you can now approve” is a nudge, not a gate.

After a config change

Change the URL, a header or the JSONPath and the green banner reverts to amber the moment you type. What happens next:

  • Save anyway and you get the toast “Saved but not approved” / “The scheduler will skip this KPI until you approve it.” Every scheduled run is then logged as skipped with “HTTP config not approved — please confirm in KPI settings”.
  • Editing only the name, target, unit, direction, period, scope or cron keeps the approval.
  • Duplicating a KPI drops the approval on the copy.
  • Importing from a JSON export always drops it. Every imported HTTP KPI must be approved again on the new machine, deliberately — an import must never be able to hand your Mac a pre-approved URL.
  • “HTTP config changed — please re-approve” is a different failure. It means the stored configuration no longer matches the stored fingerprint, i.e. kpi-definitions.json was changed outside Pulsaria.

Where header values are stored

Header names and values are saved as plain text in kpi-definitions.json, alongside the rest of the KPI definition. They do not go into the macOS Keychain — only connector credentials do that.

That has three consequences you should decide about before pasting a key:

  • Anyone who can read that file can read the token. If you moved your data directory to iCloud Drive or another sync folder, the token is in that folder too.
  • A JSON export contains the token. Treat an export file as a secret; do not mail it around.
  • Deleting the KPI removes the header from the file.

Use a read-only, narrowly scoped API key here, and rotate it if an export ever left your Mac. Where a connector exists for the same service, it is the safer route — those credentials live in the Keychain and are never included in an export.

More on both files: Where your data lives and Backup, export & moving Macs.

Three worked examples

1 — A number in a nested object

An analytics API that reports a month at a time.

URL

https://api.example.com/v1/stats?site=example.com&from={{firstDayOfMonth}}&to={{lastDayOfMonth}}

Headers — one row: Authorization / Bearer sk_live_abc123

Response

{
  "site": "example.com",
  "range": { "from": "2026-07-01", "to": "2026-07-31" },
  "totals": { "visitors": 4821, "pageviews": 11930, "bounce_rate": 38.4 }
}

JSONPath: totals.visitors4821

Sensible goal settings for this one: unit visitors, KPI type Recurring value, period Month, cron 0 9 * * *. Each fetch overwrites today’s entry with the month-to-date figure.

2 — One entry out of a list

A newsletter API that returns all your lists in one call.

URL

https://api.example.com/v1/lists

Headers — one row: X-API-Key / 8f2c9d…

Response

{
  "results": [
    { "id": "lst_weekly",  "name": "Weekly letter",   "subscriber_count": 1284 },
    { "id": "lst_product", "name": "Product updates", "subscriber_count": 412 }
  ],
  "count": 2
}

JSONPath: results[0].subscriber_count1284

The catch: [0] is whatever the API decided to put first. There is no way to select by id, and if the order ever changes your KPI silently starts tracking the other list — with no error, because the path still resolves to a number. If the API can filter server-side, do it in the URL. If you need 1284 + 412, this source cannot add them; use a Bash KPI with jq.

3 — A top-level array with numbers as strings

A support tool that returns one row per day, with everything quoted.

URL

https://api.example.com/v1/queue?day={{today}}

Response

[
  { "day": "2026-07-25", "open_tickets": "37", "first_response_minutes": "42.5" }
]

JSONPath: [0].open_tickets37

A leading [0] is the way into a response that is an array rather than an object, and a numeric string is accepted and parsed. Set Direction to Lower is better for a ticket backlog.

Watch the empty case: on a day with no rows this API returns [], the path misses, and the scheduled fetch fails with “JSONPath result not numeric” instead of recording 0. That is by design — Pulsaria will not invent a zero for a custom HTTP source.

Error messages

From a test run, where the messages are the most specific:

MessageWhat it means
HTTP 401, HTTP 404, HTTP 500, …The API answered with a non-2xx status. The status is all you get; the body is in the panel
Response is not valid JSONThe body did not parse. Wrong endpoint, an HTML error page, or a response cut off at 64 KB
JSONPath "totals.visitors" did not matchThe path resolved to nothing. Compare it with the Response body section
Resolved value is not numeric: …The path found something, but not a number. The first 100 characters of what it found are appended
Only http:// and https:// URLs are supportedThe URL uses another scheme
Empty URLThe URL field held only whitespace
Request failed: …DNS, TLS, connection or timeout. The raw message from the network layer, cut at 300 characters

From a live run — the scheduler or Run now — the wording is coarser and a few new messages appear:

MessageWhat it means
HTTP config not approved — please confirm in KPI settingsNever approved. Logged as a skipped attempt, not a failure
HTTP config changed — please re-approveThe stored config no longer matches the stored fingerprint
URL or JSONPath missingOne of the two fields is empty in storage
JSONPath result not numericThe path missed or the value was not a number — a live run does not tell the two apart
Trial expired, License revokedRead-only mode. This one is a toast on Run now, not a log row: nothing is requested and nothing is recorded. See Trial & licence

When a scheduled fetch reports “JSONPath result not numeric”, open the KPI and press Run test. The test run splits that single message into “did not match” and “is not numeric”, which are two very different fixes.

The friendly explanations connector KPIs get for 401, 403 and 429 do not apply here. A custom HTTP KPI logs the bare status code.

A failed fetch never writes a value — the KPI simply keeps its previous number. Every attempt, successful or not, lands in the Fetch Log; click a row to see the sent URL, the HTTP status, the duration and up to 4096 characters of the response body. See Fetch log.

What this source cannot do

  • No POST, PUT or GraphQL, and no request body of any kind.
  • No auth beyond “put a fixed string in a header”: no OAuth, no token refresh, no request signing, no cookies, no client certificates.
  • No summing, counting or averaging across an array, and no second value out of the same response — one request, one number, one KPI.
  • No pagination.
  • No non-JSON sources: no HTML scraping, no XML, no CSV, no plain text.
  • No configurable timeout, proxy or custom CA.
  • Automatic fetching runs only while Pulsaria is open, and only on macOS. See Schedules & refresh.

When you hit one of these, Shell commands is the escape hatch — anything curl and jq can do, with the same approval step and the same 10-second limit.

Where to go next

Didn't find it?

Write to us — a real person reads every mail, usually the one who wrote the feature.

admin@one-pixel-ahead.com