# Tickingora API — overview & quick start Tickingora watches things that must **happen on time**: cron jobs, backups, imports, integrations (**Heartbeat**) and HTTP endpoints (**HTTP Check**). If the expected signal does not arrive within its time window — or your endpoint does not answer correctly at the scheduled time — you get an e-mail alert, and another one when it recovers. The Management API lets you (or an AI coding agent) do everything the dashboard wizard does: create monitors, set schedules and alert recipients, read history. It uses **the same validation, plan limits and scheduler** as the dashboard, so a monitor created through the API looks and behaves exactly like one created by hand. - Base URL: `https://dev.tickingora.com/api/v1` - Format: JSON in, JSON out. Times are UTC, ISO 8601 (`2026-10-26T05:00:00Z`). - Auth: `Authorization: Bearer ` — see [Authentication](authentication). - Contract: [OpenAPI 3.1](openapi.yaml) · [JSON](openapi.json) · for agents: [llms.txt](https://dev.tickingora.com/llms.txt) > Tickingora is currently a **test version**, provided without any guarantee. Do not rely on it as your only safeguard. ## Quick start: monitor a nightly backup in 3 minutes ### 1. Create an API key In the dashboard open **Settings → API keys**, give the key a name, select `monitors:write` (and `monitors:read`) and copy the key. It is shown only once. ```bash export TICKINGORA_API_KEY='12|tk_…' ``` ### 2. Create a Heartbeat monitor The backup should finish every night between 02:00 and 03:00 (Warsaw time): ```bash curl -sS -X POST https://dev.tickingora.com/api/v1/monitors \ -H "Authorization: Bearer $TICKINGORA_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: nightly-backup-v1" \ -d '{ "type": "heartbeat", "external_id": "production:nightly-backup", "name": "Nightly database backup", "timezone": "Europe/Warsaw", "schedule": { "preset": "daily", "days": [1,2,3,4,5,6,7], "start": "02:00", "end": "03:00" } }' ``` The response contains the monitor and, **only this once**, its secret ping URL: ```json { "data": { "id": 42, "external_id": "production:nightly-backup", "type": "heartbeat", "status": "waiting", "name": "Nightly database backup", "heartbeat": { "ping_url": "https://dev.tickingora.com/ping/kN3xQ7…", "ping_url_note": "Store this URL now: it is shown only once. Lost it? Call rotate-token." }, "next_runs": [ { "start": "2026-10-27T01:00:00Z", "end": "2026-10-27T02:00:00Z" } ] } } ``` Store the `ping_url` as a secret right away. Tickingora keeps only a hash of the token, so the URL is shown **once** — in this response (or on the dashboard right after creating the monitor). The only exception is a retry of this exact request with the same `Idempotency-Key` within 24 hours: it gets the same response, ping URL included. Lost it? Generate a new one with [`rotate-token`](reference#rotateToken) or **Generate new URL** in the dashboard; the old one stops working. `external_id` is your own stable name for the monitor. Running the same set-up again — tomorrow or in a year — returns `409 MONITOR_ALREADY_EXISTS` with the existing monitor's id instead of creating a duplicate. `Idempotency-Key` additionally makes a single request safe to retry (for 24 hours). ### 3. Ping after the job **succeeds** Cron does not see the variables of your interactive shell, so keep the URL in a file readable only by the job's user: ```bash sudo install -d -o root -g backup -m 750 /etc/tickingora sudo install -o backup -g backup -m 600 /dev/null /etc/tickingora/backup.env sudoedit /etc/tickingora/backup.env # TICKINGORA_PING_URL=https://…/ping/… ``` ```cron # crontab of the "backup" user: the ping runs only if the backup exited with status 0 30 2 * * * . /etc/tickingora/backup.env && /usr/local/bin/backup.sh && { curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2; } ``` A failed ping is reported but never changes the backup's own result. More patterns (wrapper script, systemd, PHP, Python, Node) in [Heartbeat](heartbeat). Check the connection without affecting the schedule — a **test signal** is recorded but never counts for a window: ```bash curl -fsS "$TICKINGORA_PING_URL?test=1" # OK (test signal, not counted) ``` ### 4. Read the history ```bash curl -sS https://dev.tickingora.com/api/v1/monitors/42/runs -H "Authorization: Bearer $TICKINGORA_API_KEY" ``` If no signal arrives between 02:00 and 03:00, that window becomes `missed`, an incident opens and you get **one** alert e-mail. The next successful window closes the incident and sends a recovery e-mail. ## The same in other languages ### PHP ```php $response = (new GuzzleHttp\Client())->post('https://dev.tickingora.com/api/v1/monitors', [ 'headers' => [ 'Authorization' => 'Bearer ' . getenv('TICKINGORA_API_KEY'), 'Idempotency-Key' => 'nightly-backup-v1', ], 'json' => [ 'type' => 'heartbeat', 'external_id' => 'production:nightly-backup', 'name' => 'Nightly database backup', 'timezone' => 'Europe/Warsaw', 'schedule' => ['preset' => 'daily', 'days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '02:00', 'end' => '03:00'], ], ]); $pingUrl = json_decode((string) $response->getBody(), true)['data']['heartbeat']['ping_url']; ``` ### Python ```python import os, requests r = requests.post( "https://dev.tickingora.com/api/v1/monitors", headers={"Authorization": f"Bearer {os.environ['TICKINGORA_API_KEY']}", "Idempotency-Key": "nightly-backup-v1"}, json={ "type": "heartbeat", "external_id": "production:nightly-backup", "name": "Nightly database backup", "timezone": "Europe/Warsaw", "schedule": {"preset": "daily", "days": [1, 2, 3, 4, 5, 6, 7], "start": "02:00", "end": "03:00"}, }, timeout=15, ) r.raise_for_status() ping_url = r.json()["data"]["heartbeat"]["ping_url"] ``` ### JavaScript / TypeScript ```ts const res = await fetch("https://dev.tickingora.com/api/v1/monitors", { method: "POST", headers: { Authorization: `Bearer ${process.env.TICKINGORA_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "nightly-backup-v1", }, body: JSON.stringify({ type: "heartbeat", external_id: "production:nightly-backup", name: "Nightly database backup", timezone: "Europe/Warsaw", schedule: { preset: "daily", days: [1, 2, 3, 4, 5, 6, 7], start: "02:00", end: "03:00" }, }), }); if (!res.ok) throw new Error((await res.json()).error.message); const { data } = await res.json(); const pingUrl: string = data.heartbeat.ping_url; ``` ## Where next - [Monitors & schedules](monitors) — presets, windows, holidays, time zones, PATCH. - [Heartbeat](heartbeat) — sending signals correctly from cron, PHP, Python, Node, systemd. - [HTTP Checks](http-checks) — expected status, headers with secrets, retries, recovery checks. - [For AI coding agents](agents) — rules and a copy-paste prompt. - [API reference](reference) — every endpoint and field. --- # Authentication & API keys Every request needs an API key in the `Authorization` header, over HTTPS: ```http GET /api/v1/monitors HTTP/1.1 Host: dev.tickingora.com Authorization: Bearer 12|tk_9b2f… ``` The dashboard session cookie is **not** accepted by the API — use a key, even from your own scripts. ## Creating keys Open **Settings → API keys** in the dashboard. Each key has: | Property | Notes | | --- | --- | | Name | For you, e.g. `CI`, `backup server`, `coding agent`. | | Scopes | What the key may do (below). Choose the minimum needed. | | Expiry | Never, 30, 90 or 365 days. Expired keys get `401 UNAUTHENTICATED`. | The secret is shown **once**, when the key is created. Tickingora stores only a hash, so a lost key cannot be recovered — create a new one and revoke the old one. Keys start with `tk_` after the numeric id (`12|tk_…`), which makes leaked keys easy to detect by secret scanners. Up to 10 keys per account. ## Scopes | Scope | Allows | | --- | --- | | `monitors:read` | List and read monitors, run history. | | `monitors:write` | Create, update, pause, resume, delete monitors, rotate Heartbeat tokens. Also allows reading monitors. | | `incidents:read` | Read incident history (`GET /monitors/{id}/incidents`). | `GET /account` (plan, usage and the key's own scopes) works with **any** scope. A request without the required scope gets `403 FORBIDDEN_SCOPE` with `details.required_scopes`. A read-only key (`monitors:read`) is a good fit for dashboards and status scripts: it can never create, change or delete monitors, and it never reveals Heartbeat ping URLs. ## API keys vs. Heartbeat ping URLs They are different secrets with different power: | | API key | Heartbeat ping URL | | --- | --- | --- | | Purpose | Manage your account's monitors | Report that **one** job ran | | Where it lives | Your automation, CI, agent | The job itself (cron, script) | | Stored as | Hash — shown once, when created | Hash — shown once, on create and rotation (plus an idempotent replay of the same create within 24 h) | | Where you see it | Settings → API keys, right after creating it | `POST /monitors`, `rotate-token`, or the dashboard right after creating/rotating | | Lost it? | Create a new key, revoke the old one | Rotate: `POST /monitors/{id}/rotate-token` | | If leaked | Revoke the key in Settings | Rotate it | Never put an API key into a URL or a ping command. ## Revoking **Settings → API keys → Revoke** stops the key immediately. Revoking does not affect monitors or their ping URLs. ## Usage log Tickingora records each API request (key, method, path, status, IP address, duration — never the key or body) for 30 days, and shows *last used* next to every key. --- # Monitors & schedules A monitor is either a **Heartbeat** (your job reports to Tickingora) or an **HTTP Check** (Tickingora calls your endpoint). Both share the same schedule, calendar and alerting model. ```json { "type": "heartbeat", "name": "Bank statement import", "description": "MT940 import and payment matching", "timezone": "Europe/Warsaw", "schedule": { "preset": "daily", "days": [1,2,3,4,5], "start": "06:00", "end": "07:00" }, "calendar": { "skip_holidays": true, "excluded_dates": ["2026-12-31"] }, "notifications": { "extra_email": "finance@example.com", "on_failure": true, "on_recovery": true } } ``` | Field | Required | Notes | | --- | --- | --- | | `type` | yes (create) | `heartbeat` or `http`. Cannot be changed later. | | `external_id` | no | Your stable technical id, unique in the account, up to 100 characters (`A–Z a–z 0–9 . _ : / -`), e.g. `production:nightly-backup`. | | `name` | yes | Up to 120 characters; shown in the dashboard and alerts. | | `description` | no | Up to 500 characters. | | `timezone` | no | IANA name; defaults to the account time zone. The schedule is interpreted in it. | | `schedule` | yes | One of three presets (below). | | `calendar` | no | `skip_holidays` (Polish public holidays, default `false`), `excluded_dates` (`YYYY-MM-DD`). | | `http` | for `http` | See [HTTP Checks](http-checks). | | `notifications` | no | One `extra_email` besides the account e-mail (it receives alerts after confirming the address — see [Incidents & alerts](alerts#confirming-the-extra-address)); `on_failure` / `on_recovery` toggles. | | `recovery` | no | HTTP only; see [HTTP Checks](http-checks). | Unknown fields are rejected with `VALIDATION_ERROR`, so a typo such as `"shedule"` never silently falls back to a default. ## Finding monitors by `external_id` Pick an `external_id` that names the job, not the moment — `production:nightly-backup`, `acme-shop/invoice-export`. Then: - `GET /monitors?external_id=production:nightly-backup` finds it; - creating another monitor with the same `external_id` returns `409 MONITOR_ALREADY_EXISTS` with `details.monitor_id` — so a set-up script or an agent can run again at any time without creating duplicates; - `PATCH` can change it (or set `null`); deleting a monitor frees its `external_id`. The id is not a secret and is shown in the dashboard. Monitors created in the dashboard have no `external_id` until you set one with `PATCH`. ## Schedules Days are ISO weekdays: `1` = Monday … `7` = Sunday. Times are `HH:MM` in the monitor's time zone. **Heartbeat windows are half-open `[start, end)`**: a signal at 06:59:59 counts for `06:00–07:00`; a signal at exactly 07:00 belongs to the next window (if there is one). HTTP Checks are points in time and have no `end`. ### `daily` — one window (or check) per selected day ```json { "preset": "daily", "days": [1,2,3,4,5], "start": "06:00", "end": "07:00" } ``` For an HTTP Check omit `end`: `{ "preset": "daily", "days": [1,2,3,4,5], "start": "07:00" }`. A Heartbeat window may cross midnight, e.g. `"start": "23:30", "end": "00:30"`; it belongs to the day it starts on. ### `hourly` — every hour between two hours ```json { "preset": "hourly", "days": [1,2,3,4,5], "hour_from": 8, "hour_to": 18, "minute_start": 0, "minute_end": 15 } ``` Windows 08:00–08:15, 09:00–09:15 … 18:00–18:15. `hour_to` is the **last hour that has a window**. For Heartbeats, `minute_end` equal to `minute_start` means a full-hour window (08:00–09:00, 09:00–10:00, …). HTTP Checks omit `minute_end`. ### `custom` — explicit times ```json { "preset": "custom", "days": [1,2,3,4,5], "windows": [ { "start": "06:00", "end": "07:00" }, { "start": "12:00", "end": "12:30" } ] } ``` Up to 24 windows per day; HTTP Checks give only `start` for each entry. ### Rules enforced on every schedule - Windows of one monitor **must not overlap**. - Free plan: at most **one window or check per 60 minutes**. - Each preset accepts only its own fields; others are reported as errors. ### Calendar - `skip_holidays: true` skips Polish public holidays (New Year, Epiphany, Easter Sunday and Monday, 1 and 3 May, Pentecost, Corpus Christi, 15 August, 1 and 11 November, 24–26 December). - `excluded_dates` adds your own days off. - On excluded days nothing is expected and nothing alerts. ### Daylight saving time Schedules follow wall-clock time in the monitor's time zone, with deterministic rules: - A start time that does not exist (spring forward) moves forward by the gap — 02:30 becomes 03:30. - A start time that happens twice (fall back) uses its first occurrence. - Window length is real time: a 60-minute window is always 60 minutes. `next_runs` in every monitor response shows the next five windows (or checks) in UTC, with holidays, exclusions and DST already applied — the quickest way to confirm a schedule. ## Updating with PATCH `PATCH /monitors/{id}` accepts any subset of the fields above: - top-level fields and the fields inside `calendar`, `http`, `notifications` and `recovery` replace their current values; - `schedule` and arrays (such as `http.headers` or `calendar.excluded_dates`) are replaced **as a whole**; - the change applies **from the next window** — the window in progress and all history stay as they were. ```bash curl -sS -X PATCH https://dev.tickingora.com/api/v1/monitors/42 \ -H "Authorization: Bearer $TICKINGORA_API_KEY" -H "Content-Type: application/json" \ -d '{ "schedule": { "preset": "daily", "days": [1,2,3,4,5,6,7], "start": "01:30", "end": "03:00" } }' ``` ## Status | Monitor `status` | Meaning | | --- | --- | | `waiting` | Created or resumed; no window has finished yet. | | `healthy` | The last finished window/check succeeded. | | `alerting` | An incident is open. | | `paused` | Nothing is expected; no alerts. | | `unknown` | The last result could not be evaluated because of a Tickingora-side problem. | Pausing (`POST /monitors/{id}/pause`) removes upcoming windows and closes an open incident without a recovery e-mail. Resuming counts towards the plan's active-monitor limit. Deleting stops monitoring immediately and revokes the ping URL. ## Plan limits (Free) | Limit | Free | | --- | --- | | Active Heartbeat monitors | 20 | | Active HTTP Checks | 3 | | Most frequent schedule | every 60 minutes | | Extra headers per HTTP Check | 5 | | Recovery check interval | 15, 30 or 60 minutes | | Detailed history | 30 days | The same limits apply to the dashboard and the API. Exceeding one returns `403 PLAN_LIMIT_REACHED`; `GET /account` shows current usage. --- # Heartbeat A Heartbeat monitor has a secret **ping URL**. Your job calls it with `GET` when it has finished successfully: ``` https://dev.tickingora.com/ping/ ``` ## How signals are counted - Each scheduled window is a separate run: `[start, end)`, start included, end excluded. - **One** signal inside the window is enough for `success`. All signals are recorded and counted (`signal_count`). - No signal until the end of the window → `missed`, an incident opens and you get an alert. - There is no "late" status: a signal after the window never changes it. A signal outside any window is recorded as *outside of schedule* and changes nothing. - The receive time decides — the moment Tickingora accepted the request. The response is `200 OK` with a plain-text body (`OK`, `OK (outside of schedule)`, `OK (monitor paused)`) and `Cache-Control: no-store`. An unknown or rotated token gets `404`. ## Send it after success — never before The ping means "the job **completed**". Put it after the work, and only on success. Two rules for every example below: 1. **The job decides, not the ping.** If the job fails, no ping is sent. 2. **Monitoring must not change the job's result.** If Tickingora is unreachable, the failed ping is reported, but the job still ends as successful — otherwise a monitoring outage would turn your successful backups into "failed" ones (and possibly trigger retries). Also never print the ping URL in error messages: it is a secret. ```bash #!/usr/bin/env bash set -euo pipefail # stop on the first error — the ping is never reached pg_dump mydb | gzip > /backups/mydb-$(date +%F).sql.gz curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2 ``` ### Cron: make the URL available to the job Cron starts jobs with a minimal environment: variables exported in your `~/.bashrc` or interactive shell are **not** there, and the default shell is `/bin/sh` (use `.`, not `source`). Keep the URL in an environment file that only the job's user can read: ```bash sudo install -d -o root -g backup -m 750 /etc/tickingora sudo install -o backup -g backup -m 600 /dev/null /etc/tickingora/backup.env sudoedit /etc/tickingora/backup.env ``` ```bash # /etc/tickingora/backup.env (mode 600, owner backup) TICKINGORA_PING_URL=https://…/ping/… ``` Then either load it in the crontab line and chain with `&&`: ```cron 0 2 * * * . /etc/tickingora/backup.env && /opt/jobs/backup.sh && { curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2; } ``` The `{ … || … ; }` group keeps a failed ping from failing the line — but do **not** write `backup.sh && curl … || true`: that would also hide a failed backup. …or, cleaner, use a small wrapper and keep the crontab simple: ```bash #!/bin/sh # /opt/jobs/backup-and-ping.sh set -eu . /etc/tickingora/backup.env /opt/jobs/backup.sh # set -e: a failure stops here, no ping curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2 ``` ```cron 0 2 * * * /opt/jobs/backup-and-ping.sh >> /var/log/backup.log 2>&1 ``` You can also put `TICKINGORA_PING_URL=…` as a line at the top of the crontab — simpler, but then the secret lives in the crontab, readable by the user and visible in `crontab -l`. Never commit the env file. Check it in the job's own environment: `sudo -u backup sh -c '. /etc/tickingora/backup.env && curl -fsS "$TICKINGORA_PING_URL?test=1"'`. `-f` makes curl fail on HTTP errors, `-m 10` limits the time, `--retry 3` survives short network glitches. curl's own error messages contain only the host name, not the token. ### PHP ```php function runImport(): void { /* … */ } runImport(); // throws on failure, so the ping below is skipped // A failed ping must not fail the job; @ also keeps the URL out of PHP warnings. if (@file_get_contents(getenv('TICKINGORA_PING_URL'), false, stream_context_create(['http' => ['timeout' => 10]])) === false) { error_log('Tickingora ping failed'); } ``` Laravel scheduler — `rescue(..., report: false)` keeps a failed ping from failing the task **and** from writing the exception (whose message contains the URL) to the log: ```php // config/services.php: 'tickingora' => ['ping_url' => env('TICKINGORA_PING_URL')], Schedule::command('import:bank')->dailyAt('06:05') ->onSuccess(fn () => rescue( fn () => Http::timeout(10)->retry(3, 2000)->get(config('services.tickingora.ping_url')), fn () => Log::warning('Tickingora ping failed'), report: false, )); ``` ### Python ```python import os, sys, requests def main(): run_import() # raises on failure — no ping try: requests.get(os.environ["TICKINGORA_PING_URL"], timeout=10).raise_for_status() except requests.RequestException: print("Tickingora ping failed", file=sys.stderr) # not the exception: it contains the URL if __name__ == "__main__": main() ``` ### Node.js / TypeScript ```ts await runSync(); // throws on failure — no ping await fetch(process.env.TICKINGORA_PING_URL!, { signal: AbortSignal.timeout(10_000) }) .then((res) => { if (!res.ok) throw new Error(); }) .catch(() => console.error("Tickingora ping failed")); // never rethrow, never log the URL ``` ### systemd ```ini [Service] Type=oneshot ExecStart=/opt/jobs/backup.sh ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 3 -o /dev/null ${TICKINGORA_PING_URL} EnvironmentFile=/etc/tickingora/backup.env ``` `ExecStartPost` runs only if `ExecStart` succeeded. The `-` prefix tells systemd to ignore the curl exit code: without it, an unreachable Tickingora would mark the unit as **failed** even though the backup succeeded. curl's error still appears in the journal (with the host name only). Keep `EnvironmentFile` readable only by root (systemd reads it before dropping privileges): `chmod 600`. ## Testing the connection Add `?test=1` to check the token and connectivity from the job's machine. The test signal is recorded (the dashboard shows "test signal received") but **never** counts for a window, opens or closes incidents: ```bash curl -fsS "$TICKINGORA_PING_URL?test=1" # OK (test signal, not counted) ``` ## Limits - 60 signals per minute per ping URL and 600 per minute per IP address; above that the response is `429`. - Only the first 100 signals per window are stored individually; the rest are counted. ## Rotating the token If the URL leaks (logs, a public repository) or you lost it, rotate it — the old URL stops working at once and the new one is shown in the response (or in the dashboard: **Generate new URL**): ```bash curl -sS -X POST https://dev.tickingora.com/api/v1/monitors/42/rotate-token -H "Authorization: Bearer $TICKINGORA_API_KEY" ``` The ping URL is a secret: keep it in an environment file or a secret manager, not in code. Tickingora stores only a hash of the token (so it cannot show an old URL again — rotate instead) and never writes the URL to access logs. --- # HTTP Checks An HTTP Check makes Tickingora send `GET` to your endpoint at the scheduled times and evaluate the response. ```json { "type": "http", "name": "Production API", "timezone": "Europe/Warsaw", "schedule": { "preset": "custom", "days": [1,2,3,4,5,6,7], "windows": [{ "start": "07:00" }, { "start": "19:00" }] }, "http": { "url": "https://api.example.com/health", "expected_status": "2xx", "headers": [{ "name": "Authorization", "value": "Bearer status-check-token" }] }, "recovery": { "enabled": true, "interval_minutes": 15 } } ``` ## Target URL - Only **public HTTPS** addresses, on port 443 or 8443. - `localhost`, private and link-local ranges, cloud metadata endpoints and `.local` / `.internal` names are blocked. DNS is checked before **every** attempt and the connection is pinned to the checked address, so a DNS change cannot redirect a check to an internal host. - Redirects are **not** followed: a `301` is a response like any other (useful with `expected_status: "301"`). - Only the status line and headers are read; the body is never downloaded or stored. ## Expected status | `expected_status` | Success when the response is | | --- | --- | | `"200"` (default) | exactly 200 | | `"2xx"` | any 200–299 | | `"204"`, `"301"`, … | exactly that code (100–599) | ## Attempts and retries - Each attempt has a **10 s** timeout. - Up to **3 attempts**: the first one plus two retries, **30 s** after the previous attempt finished. - The first response that meets `expected_status` ends the check with `success`. - Timeouts, DNS/TLS/connection errors and wrong status codes are failed attempts. After the third one the check is `failed`, an incident opens and you get an alert. - Every attempt is recorded with its status code, duration and error — `GET /monitors/{id}/runs` returns them in `attempts` (`number`, `at`, `http_status`, `duration_ms`, `error_type`, `error`). - If Tickingora itself could not run a check on time, the run is `not_executed` and nothing is blamed on your service. Retry settings are fixed in the MVP. ## Headers with secrets Add up to 5 headers (Free plan), e.g. `Authorization` or `X-API-Key`. - Values are **encrypted** at rest and **never returned** by the API — responses show `{ "name": "Authorization", "has_value": true }`. - They are kept out of logs, error messages and e-mails. - On `PATCH`, `http.headers` replaces the list. A header sent **without** `value` keeps its stored secret; send a `value` to replace it; leave a header out to delete it. - Not allowed (they change transport or routing): `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Keep-Alive`, `Upgrade`, `TE`, `Trailer`, `Expect`, `Forwarded`, `Via`, `X-Forwarded-*`, `X-Real-IP`, `Proxy-*`. ```bash # Rotate the check's token, keep the API key header as it is curl -sS -X PATCH https://dev.tickingora.com/api/v1/monitors/7 \ -H "Authorization: Bearer $TICKINGORA_API_KEY" -H "Content-Type: application/json" \ -d '{ "http": { "headers": [ { "name": "X-API-Key" }, { "name": "Authorization", "value": "Bearer new-status-token" } ] } }' ``` Use a dedicated token with minimal permissions for status checks. ## Recovery checks With `recovery.enabled`, after a failure Tickingora also checks every `interval_minutes` (Free: 15, 30 or 60) — one attempt each — until the service answers correctly. Then the incident closes, a recovery e-mail is sent and the extra checks stop. Regular and recovery checks never hit your endpoint at the same time. ## Testing The dashboard has **Test now** (also in the wizard before saving). It uses the same URL rules and headers but creates no runs, incidents or statistics. --- # Incidents & alerts ## Run results | Status | Monitor type | Meaning | | --- | --- | --- | | `waiting` | Heartbeat | The window is planned or open, no signal yet. | | `scheduled` | HTTP | The check is planned. | | `running` | HTTP | Attempts in progress. | | `success` | both | A signal arrived in the window / a response met `expected_status`. | | `missed` | Heartbeat | No signal until the end of the window. | | `failed` | HTTP | All attempts failed. | | `not_executed` | HTTP | Tickingora could not run the check on time — not your fault, no alert. | | `unknown` | Heartbeat | Tickingora was down during the window — not your fault, no alert. | Finished results are permanent: a later success never rewrites a `missed` window. ## Incidents 1. The first `missed` or `failed` result opens an incident and sends **one** alert. 2. Further failures while the incident is open are recorded (`failed_runs_count`) but do not send more e-mails. 3. The first later **successful** scheduled window or check — or a successful recovery check — closes it as `recovered` and sends a recovery e-mail. 4. Pausing a monitor closes an open incident as `closed`, without an e-mail. A monitor has at most one open incident (`open_incident` in the monitor response). ## Recipients Alerts go to the account e-mail and optionally one extra address (`notifications.extra_email`). Each kind can be switched off with `notifications.on_failure` and `notifications.on_recovery`. Alerts are sent from `tick@tickingora.com`. ### Confirming the extra address Nobody can be signed up for alerts by someone else: 1. When a monitor is saved with a new extra address, Tickingora sends that address a confirmation e-mail (from `hello@tickingora.com`). 2. The address receives alerts **only after its owner confirms** (the link opens a page with a button — mail scanners that open links cannot confirm on anyone's behalf). Links expire after 7 days. 3. Every alert to the extra address has an **unsubscribe** link. An unsubscribed address gets no alerts and no further confirmation requests from that account. `notifications.extra_email_status` shows `pending`, `verified` or `unsubscribed`. A confirmed address is confirmed for all monitors of the account. Until then the dashboard shows a notice listing every address that does not receive alerts yet, with the monitors using it. Confirmation e-mails are limited to one per hour per address when a monitor is saved, a manual resend from the dashboard once per 10 minutes, and 10 per account per 24 hours. The dashboard always shows the real time of the next allowed request, taking all limits into account. Delivery is idempotent: a retried job never sends the same alert twice. The dashboard shows each delivery and whether the mail provider accepted it (acceptance is not the same as the message being read). --- # For AI coding agents This page is written for coding assistants (and the people prompting them) that add monitoring to a project. Machine-readable entry points: [llms.txt](https://dev.tickingora.com/llms.txt), [llms-full.txt](https://dev.tickingora.com/llms-full.txt), [openapi.json](openapi.json). Every guide is also available as Markdown: add `.md` to its URL. ## Rules 1. **Ping after success, never before.** The Heartbeat call is the job's last step and runs only when the job succeeded (`&&`, `set -e`, `onSuccess`, `ExecStartPost`). A ping at the start of a job reports work that has not happened. **A failed ping must never fail the job** (`… || echo "ping failed" >&2`, systemd `ExecStartPost=-…`, `rescue(..., report: false)`, `try/except`), and error messages must not contain the ping URL. See [Heartbeat](heartbeat#send-it-after-success-never-before). 2. **Give every monitor an `external_id`** derived from the project and job (`acme-shop:invoice-export`). It is unique in the account and permanent: running the set-up again — even months later — returns `409 MONITOR_ALREADY_EXISTS` with `details.monitor_id` instead of a duplicate. Check first with `GET /monitors?external_id=…`. 3. **Also send an `Idempotency-Key`** on `POST /monitors` so a retried request (timeout, crash) is safe. It is remembered for 24 hours only — `external_id` is what prevents duplicates long-term. 4. **Store the `ping_url` as a secret** right after creation — it is shown only once (tokens are stored hashed); if the response got lost, retrying the same request with the same `Idempotency-Key` within 24 hours returns it again. Never commit it, never print it in logs. If the monitor already exists and the job has no URL, call `rotate-token` (the previous URL stops working). 5. **Make the URL available where the job runs.** Cron does not inherit interactive shell variables: use an env file with mode 600 owned by the job user (see [Heartbeat](heartbeat#cron-make-the-url-available-to-the-job)), systemd `EnvironmentFile`, or the platform's secret store. 6. **Never put the API key in the job.** The job needs only its ping URL. The API key belongs to your automation, with the minimum scopes. 7. **Match the window to reality.** The window must contain the time the job *finishes*. A job starting 02:00 and taking up to 40 minutes needs e.g. `02:00–03:00`. 8. **Verify with `?test=1`.** It proves the job host can reach Tickingora without counting for a window. 9. **Read errors.** On `422` fix the fields listed in `error.details`; on `409 MONITOR_ALREADY_EXISTS` use `details.monitor_id` (`GET`, then `PATCH` if the configuration differs); on `403 PLAN_LIMIT_REACHED` stop and tell the user; on `429` wait for `Retry-After`. 10. **Check the result**: `next_runs` in the response must show the expected windows. ## Recipe ```bash # 1. Plan: is there room on the plan? curl -sS https://dev.tickingora.com/api/v1/account -H "Authorization: Bearer $TICKINGORA_API_KEY" # 2. Already there? curl -sS "https://dev.tickingora.com/api/v1/monitors?external_id=acme-shop:invoice-export" -H "Authorization: Bearer $TICKINGORA_API_KEY" # 3. Create (safe to repeat: 409 MONITOR_ALREADY_EXISTS if it exists) curl -sS -X POST https://dev.tickingora.com/api/v1/monitors \ -H "Authorization: Bearer $TICKINGORA_API_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: acme-shop:invoice-export:$(date +%F)" \ -d '{"type":"heartbeat","external_id":"acme-shop:invoice-export","name":"Invoice export","timezone":"Europe/Warsaw", "schedule":{"preset":"daily","days":[1,2,3,4,5],"start":"20:00","end":"21:00"}, "calendar":{"skip_holidays":true}}' # 4. Save data.heartbeat.ping_url as TICKINGORA_PING_URL where the job runs # (env file 600 / systemd EnvironmentFile / platform secret store) — shown only once # 5. Add to the job, after the work: # php artisan invoices:export && { curl -fsS -m 10 --retry 3 "$TICKINGORA_PING_URL" > /dev/null || echo "Tickingora ping failed" >&2; } # 6. Verify from the job host, in the job's own environment curl -fsS "$TICKINGORA_PING_URL?test=1" ``` ## Prompt snippet > Add Tickingora monitoring for the `` job. Use the Tickingora API (https://dev.tickingora.com/api/v1, key in `TICKINGORA_API_KEY`) to create a Heartbeat monitor with `external_id: ":"` (reuse it if it already exists) and an `Idempotency-Key`, a schedule window that covers when the job finishes, in the `Europe/Warsaw` time zone, skipping Polish holidays if the job does not run on them. Store `data.heartbeat.ping_url` as the `TICKINGORA_PING_URL` secret where the job runs (cron does not see shell variables; use an env file with mode 600) — do not commit it. Call the ping URL with `curl -fsS -m 10 --retry 3` only after the job succeeded. Verify with `?test=1` and show me `next_runs`. Follow https://dev.tickingora.com/docs/agents.md. ## Out of scope today There is no dedicated MCP server yet — use the REST API and the OpenAPI document. --- # Errors Every error has the same shape: ```json { "error": { "code": "VALIDATION_ERROR", "message": "The given data was invalid.", "details": { "schedule": ["Schedule windows must not overlap (Mon 06:00 and Mon 06:30)."], "http.url": ["Only HTTPS URLs are allowed."] } } } ``` Branch on `code` — it is stable. `message` is for people and may change. For validation errors `details` maps field paths (as in the request body, e.g. `http.headers.0.name`) to messages. | HTTP | `code` | Meaning — what to do | | --- | --- | --- | | 401 | `UNAUTHENTICATED` | Missing, invalid, revoked or expired key. Send `Authorization: Bearer `. | | 403 | `FORBIDDEN_SCOPE` | The key lacks a scope (`details.required_scopes`). Use a key with it. | | 403 | `PLAN_LIMIT_REACHED` | Active-monitor limit of the plan. Pause or delete a monitor first. | | 404 | `MONITOR_NOT_FOUND` | No such monitor in this account (other accounts' monitors do not exist for you). | | 404 | `NOT_FOUND` | Unknown endpoint. | | 405 | `METHOD_NOT_ALLOWED` | Wrong HTTP method for the endpoint. | | 409 | `MONITOR_ALREADY_EXISTS` | Another monitor of the account has this `external_id` — `details.monitor_id` tells which. Read or `PATCH` it instead. | | 409 | `IDEMPOTENCY_IN_PROGRESS` | The same `Idempotency-Key` is being processed. Retry in a moment. | | 409 | `UNSUPPORTED_FOR_MONITOR_TYPE` | E.g. `rotate-token` on an HTTP Check. | | 415 | `UNSUPPORTED_MEDIA_TYPE` | Send JSON with `Content-Type: application/json`. | | 422 | `VALIDATION_ERROR` | Fix the fields in `details`. Unknown fields are errors too. | | 422 | `IDEMPOTENCY_KEY_REUSED` | The key was used with a different body. Use a new key. | | 429 | `RATE_LIMITED` | Too many requests. Wait `Retry-After` seconds. | | 500 | `INTERNAL_ERROR` | Our fault. Retry later; with an `Idempotency-Key`, retrying a create is safe. | Validation messages are in English and describe the same rules as the dashboard wizard. --- # Limits, pagination & idempotency ## Rate limits Per API key: **60 requests per minute**, of which at most **20 writes** (`POST`, `PATCH`, `DELETE`). Responses carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`; above the limit you get `429 RATE_LIMITED` with `Retry-After` (seconds). Heartbeat pings have their own, separate limits (see [Heartbeat](heartbeat)). ## Pagination Lists (`/monitors`, `/monitors/{id}/runs`, `/monitors/{id}/incidents`) use cursors: ```json { "data": [ … ], "next_cursor": "eyJpZCI6NTAsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0", "has_more": true } ``` Pass `?cursor=` for the next page and `?limit=` (1–100, default 50) for the page size. Cursors stay correct while new runs are being added. Stop when `has_more` is `false`. ```bash cursor="" while :; do page=$(curl -sS "https://dev.tickingora.com/api/v1/monitors?limit=100&cursor=$cursor" -H "Authorization: Bearer $TICKINGORA_API_KEY") echo "$page" | jq -r '.data[].name' [ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break cursor=$(echo "$page" | jq -r '.next_cursor') done ``` Order: monitors by `id`; runs and incidents newest first. Runs can be filtered by `status`, `from` and `to` (ISO 8601). ## Idempotency `POST /monitors` accepts an `Idempotency-Key` header (up to 255 characters). It protects **one request** against retries — a network timeout, a crashed worker — for **24 hours**: - the first request is processed normally and its response stored for **24 hours**; - a repeat with the same key and the same body returns the stored response with `Idempotent-Replayed: true` — no second monitor; - the same key with a different body returns `422 IDEMPOTENCY_KEY_REUSED`; - a repeat while the first request is still running returns `409 IDEMPOTENCY_IN_PROGRESS`. Keys are scoped to your account. `PATCH`, `DELETE`, `pause` and `resume` are naturally idempotent. The replayed response of a Heartbeat `POST /monitors` contains the one-time `ping_url`, so a retry after a lost response still gets it. To keep that safe, stored responses are **encrypted** and **deleted as soon as the 24 hours pass** — from then on the token exists only as a hash and cannot be recovered (use `rotate-token`). After 24 hours the same key is processed as a new request. To make **repeated set-ups** (a script or an agent that runs again next week) safe for good, give the monitor an `external_id`: a second monitor with the same `external_id` is never created — the API answers `409 MONITOR_ALREADY_EXISTS` with `details.monitor_id`. Use both: `external_id` for identity, `Idempotency-Key` for retries. ## Plan limits and retention Plan limits (active monitors, frequency, headers, recovery interval) are the same as in the dashboard — see [Monitors & schedules](monitors#plan-limits-free). Run and incident history is available for 30 days on the Free plan; older entries are deleted and do not appear in the API. --- # Security & token rotation ## Secrets you handle | Secret | Who needs it | Rotate with | | --- | --- | --- | | API key | Automation that manages monitors | Create a new key, then revoke the old one in Settings → API keys | | Heartbeat ping URL | The monitored job | `POST /monitors/{id}/rotate-token` or the dashboard (**Generate new URL**) | | HTTP Check header values | Stored in Tickingora, sent to your endpoint | `PATCH` with a new `value` | ## Good practice - Give each integration its own key with the **minimum scopes**; prefer `monitors:read` for dashboards and reporting. - Set an expiry on keys used by temporary automation or agents. - Keep ping URLs and keys in environment files (mode 600, owned by the job user) or a secret manager. Do not commit them, do not print them in CI logs. Cron and systemd do not see your interactive shell's variables. - Use dedicated, low-privilege tokens for HTTP Check headers. - If anything leaks: rotate first, investigate afterwards. Rotation takes effect immediately. ## What Tickingora does - API keys are stored as hashes and shown only once. Usage is logged without the key. - Heartbeat tokens are stored as SHA-256 hashes, like API keys: the ping URL is shown once (on create and rotation, in the API or the dashboard) and cannot be shown again — a lost URL is replaced by rotating. It is never written to access logs. - The only other copy is inside an idempotent replay of `POST /monitors` (same `Idempotency-Key`, same body): that stored response is encrypted and deleted after 24 hours. - `external_id` is unique per account at the database level (`UNIQUE(user_id, external_id)`), so even simultaneous requests cannot create duplicates. - HTTP Check header values are encrypted at rest, never returned, and removed from error messages. - HTTP Checks reach only public HTTPS addresses; DNS is validated before every attempt and the connection is pinned to the validated address (SSRF protection). Redirects are not followed. - Monitors are visible only to their account — other accounts' IDs return `404`. - Ping tokens are kept out of logs at every layer: no access logs (web server, PHP-FPM), the web servers' error logs replace `/ping/` with `/ping/[REDACTED]`, PHP stack traces do not include argument values, the rate limiter keys on a hash of the token, and API usage logs record only `/api` paths. No CDN or third-party proxy sits in front of Tickingora. - Extra alert recipients must confirm their address and can unsubscribe at any time. - Everything runs over HTTPS. Found a vulnerability? Write to biuro@sixt4.pl.