# Hark — Webhooks to iPhone notifications

Hark documentation. HTML version: https://hark.ryan.ceo/docs

## Quickstart

Hark turns an HTTP request into a source-branded iPhone notification. Create a service, then POST JSON to its secret webhook URL.

### What Hark is

Anything that can send an HTTP request can notify your phone: CI jobs, coding agents, cron scripts, monitors. Each service carries its own name, avatar, and tap destination, and Hark fills in any field you omit from those service defaults.

There are two APIs, both authenticated by the same webhook token. The Notification API sends one-shot pushes and optional approval prompts. The Activity API drives a stateful Live Activity on the Lock Screen and in the Dynamic Island.

### Create a service

1. Sign in at [hark.ryan.ceo](https://hark.ryan.ceo).
2. Register your iPhone with the Hark app.
3. Create a service in the dashboard and give it a title, avatar, and tap URL.
4. Copy the secret webhook URL it returns.

### Copy the webhook URL

The plaintext token is shown when the service is created and whenever you rotate it. Treat it as a credential: anyone holding it can notify your devices.

Webhook URL:

```text
https://hark.ryan.ceo/hooks/whk_your_token
```

An unknown or rotated token returns `404` with `{ "ok": false, "error": "Unknown webhook" }`.

### Send a notification

Only `body` is required. Everything else falls back to the service defaults.

```bash
curl -X POST https://hark.ryan.ceo/hooks/whk_your_token \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: deploy-184-production' \
  -d '{
    "body": "Production deployed successfully.",
    "title": "GitHub",
    "imageUrl": "https://github.com/github.png",
    "url": "https://github.com/acme/app/actions"
  }'
```

### Read the response

```json
{
  "ok": true,
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "delivered": 1
}
```

`eventId` identifies the event in the dashboard activity log and, for interactive notifications, is the handle used to read or cancel the pending response. `delivered` is the number of push requests accepted by Expo.

> A request with no registered device still succeeds with `delivered: 0` and a `message` field, so an unpaired phone never fails your build.

## Notification API

One-shot pushes, delivered to every registered iPhone or to the devices you name. The webhook token in the URL is the only credential.

### Endpoint

| Route | Purpose |
| --- | --- |
| `POST /hooks/:token` | Send a notification. |
| `GET /hooks/:token/events/:eventId` | Read the state of an interactive response. |
| `POST /hooks/:token/events/:eventId/cancel` | Withdraw a pending interactive response. |

Send `Content-Type: application/json`. An unrecognised token returns `404`; a payload that fails validation returns `400` with an `issues` array describing each field.

### Request payload

| Field | Type | Description |
| --- | --- | --- |
| `body` | string, required | Notification message, 1 to 2,000 characters after trimming. |
| `title` | string | Sender-name override, up to 80 characters. Defaults to the service title. |
| `imageUrl` | string | Public HTTPS avatar URL, up to 2,048 characters. localhost, .local, loopback, link-local and private IP ranges are rejected. |
| `url` | string | Destination opened when the notification is tapped. http and https only, so custom app schemes and javascript: or data: URLs never reach a device. |
| `deviceIds` | string[], Pro | 1 to 50 device IDs from the dashboard. Omit to notify every active device. |
| `response` | object, Pro | Turns the notification into an approval, yes/no, or text prompt. |

### Idempotency

Send an optional `Idempotency-Key` header of 1 to 200 characters. Keys are scoped to a single service.

- Same key and payload: the original event is returned with `idempotent: true`.
- Same key while the first request is still in flight: `202 Accepted`.
- Same key with a different payload: `409 Conflict`.
- Blank or over-length key: `400`.

### Device routing

**Hark Pro** — requires a paid plan.

By default a request fans out to every active iOS device on the account, most recently seen first. Free accounts are capped at one device, so extra phones are ignored until you upgrade.

Hark Pro can pass a non-empty `deviceIds` array to target specific iPhones. Copy the stable device IDs from the dashboard. IDs that do not belong to the account return `400 Invalid device selection`; owned but inactive or non-iOS devices in the list are skipped silently.

```json
{
  "body": "The production deploy needs attention.",
  "deviceIds": ["dev_your_iphone_id"]
}
```

Sending `deviceIds` without device routing on your plan returns `402`.

### Rate limits

| Limit | Free | Pro |
| --- | --- | --- |
| Requests per minute, per service | 60 | 300 |
| Requests per minute, per account | 300 | 1,500 |
| Notifications per month | 10,000 | 100,000 |
| Active devices | 1 | Unlimited |

The per-minute counters use a rolling 60-second window and are shared across notifications, interactive responses, and Live Activity operations. A limited request returns `429` with a `Retry-After: 60` header and `retryAfterSeconds` in the body. Exhausting the monthly allowance also returns `429`, without a retry hint.

### Response shape

```json
{
  "ok": true,
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "delivered": 1
}
```

| Field | Type | Description |
| --- | --- | --- |
| `200` | ok | Accepted, or an idempotent replay. |
| `202` | ok | An identical request is still processing. |
| `400` | error | Invalid payload, key, or device selection. |
| `402` | error | The payload uses a Hark Pro feature. |
| `404` | error | Unknown webhook token. |
| `409` | error | Idempotency key reused with a new payload. |
| `429` | error | Rate limit or monthly allowance exhausted. |
| `502` | error | Every push target was rejected by Expo. |

Provider errors can embed a device push token, so they are recorded in the dashboard activity log rather than returned to the caller. Use that log to find devices that are no longer registered.

### Interactive responses

**Hark Pro** — requires a paid plan.

Hark Pro can attach a fixed response type to any notification. Supported types are `approval` (Approve or Deny), `yes_no` (Yes or No), and `text` (a short free-form reply).

```json
{
  "title": "Production deploy",
  "body": "Deploy commit 8e7fc2a?",
  "response": {
    "type": "approval",
    "expiresInSeconds": 900,
    "correlationId": "deploy-184",
    "callback": {
      "url": "https://ci.example.com/hark-response",
      "token": "private-callback-token"
    }
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `type` | string, required | `approval`, `yes_no`, or `text`. |
| `expiresInSeconds` | integer | 30 to 86,400. Defaults to 900. |
| `correlationId` | string | Your own identifier, up to 100 characters. Echoed back on the callback. |
| `callback.url` | string | Public HTTPS URL that receives the answer. |
| `callback.token` | string | 16 to 512 characters, sent back as a bearer token so you can verify Hark. |

The response body gains a `response` object alongside the usual fields:

```json
{
  "ok": true,
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "delivered": 1,
  "response": { "status": "pending", "expiresAt": "2026-07-25T18:19:04.000Z" }
}
```

### Read and cancel

**Hark Pro** — requires a paid plan.

Poll the event with the `eventId` from the send response, or withdraw it while it is still pending.

```bash
curl https://hark.ryan.ceo/hooks/whk_your_token/events/evt_Cxns2IdbF4H0TJYq

curl -X POST https://hark.ryan.ceo/hooks/whk_your_token/events/evt_Cxns2IdbF4H0TJYq/cancel
```

```json
{
  "ok": true,
  "event": {
    "id": "evt_Cxns2IdbF4H0TJYq",
    "response": {
      "status": "approved",
      "action": "approve",
      "text": null,
      "correlationId": "deploy-184",
      "respondedAt": "2026-07-25T18:06:52.000Z",
      "expiresAt": "2026-07-25T18:19:04.000Z"
    }
  }
}
```

- `status` is one of `pending`, `approved`, `denied`, `yes`, `no`, `replied`, `expired`, or `canceled`.
- For `text` prompts, `action` becomes `reply` and `text` holds the answer. For the other types `action` holds the chosen option and `text` is `null`.
- Reading a pending request after `expiresAt` settles it as `expired`.
- Cancel returns `404` if the response is not pending, and events belonging to another service are never visible.

### Callbacks

**Hark Pro** — requires a paid plan.

When a callback is configured, Hark POSTs the answer to your URL with `Authorization: Bearer <callback.token>`, `Content-Type: application/json`, and a `Hark-Callbacks/1` user agent. Redirects are not followed and the request times out after 10 seconds.

```json
{
  "type": "notification.response",
  "eventId": "evt_Cxns2IdbF4H0TJYq",
  "correlationId": "deploy-184",
  "kind": "approval",
  "status": "approved",
  "action": "approve",
  "text": null,
  "respondedAt": "2026-07-25T18:06:52.000Z"
}
```

`kind` is `approval`, `yes_no`, or `reply` — a `text` request arrives as `reply`.

Any response outside the 2xx range is a failure. Hark makes up to five attempts: once immediately, then after 30 seconds, 2 minutes, 10 minutes, and 1 hour. After the last attempt the callback is marked failed and is not retried, so treat the callback as at-least-once and key your handler on `eventId`.

> Callbacks fire only when someone actually answers. Prompts that expire or are canceled never call back — poll the event route if you need to observe those outcomes.

## Activity API

**Hark Pro** — requires a paid plan.

A Live Activity is a stateful card on the Lock Screen and in the Dynamic Island. Start one, push partial updates as work progresses, then end it. Same webhook token, nested routes.

### Start an activity

| Route | Purpose |
| --- | --- |
| `POST /hooks/:token/live-activities` | Start an activity. Returns 201. |
| `GET /hooks/:token/live-activities/:id` | Read the current state. |
| `PATCH /hooks/:token/live-activities/:id` | Apply a partial update. |
| `POST /hooks/:token/live-activities/:id/end` | Settle and dismiss the activity. |

Only `title` and `status` are required.

```bash
curl -X POST https://hark.ryan.ceo/hooks/whk_your_token/live-activities \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: deploy-184-start' \
  -d '{
    "title": "Deploy #184",
    "status": "Building",
    "progress": 0,
    "symbol": "build",
    "accentColor": "#FF9F0A"
  }'
```

```json
{
  "ok": true,
  "activityId": "act_9Rk2wQpLm4Tz",
  "sequence": 0,
  "status": "active",
  "accepted": 1,
  "failed": 0,
  "state": { "title": "Deploy #184", "status": "Building", "progress": 0, "symbol": "build" },
  "expiresAt": "2026-07-26T02:04:11.000Z",
  "staleAt": "2026-07-25T22:04:11.000Z",
  "endedAt": null
}
```

Use `activityId` in the update and end routes. If you pass your own `key` when starting, that value works in the URL too, so a script can address its activity without storing the generated ID. `Idempotency-Key` works on all three write routes and is scoped to the service: a replay returns `idempotent: true`, and reusing a key with a different payload returns `409`.

> Live Activities need a device running a Hark build that has registered a push-to-start token. If no device qualifies, the start still returns `201` with `accepted: 0` and an explanatory `message`.

### Update an activity

`PATCH` merges into the current state, so send only what changed. At least one field other than `ifSequence` is required. Each accepted update increments `sequence`.

```bash
curl -X PATCH https://hark.ryan.ceo/hooks/whk_your_token/live-activities/act_9Rk2wQpLm4Tz \
  -H 'Content-Type: application/json' \
  -d '{ "status": "Testing", "progress": 0.6, "accentColor": "#64D2FF" }'
```

- Pass `null` for `detail` or `progress` to clear the field.
- Pass `ifSequence` to make the write conditional. A mismatch returns `409 Sequence conflict` along with the current state so you can reconcile.
- Updating an activity that has already ended or expired returns `409 Live Activity is already terminal`.

### End an activity

```bash
curl -X POST https://hark.ryan.ceo/hooks/whk_your_token/live-activities/act_9Rk2wQpLm4Tz/end \
  -H 'Content-Type: application/json' \
  -d '{ "status": "Deployed", "progress": 1, "symbol": "success" }'
```

The body is optional. `dismissAfterSeconds` (0 to 14,400, default 0) controls how long the finished card lingers on the Lock Screen before iOS removes it.

> `status` and `symbol` have defaults on this route, so omitting them overwrites the live values with `"Complete"` and `success`. Send them explicitly if you want the final card to read differently.

### Fields and appearance

| Field | Type | Description |
| --- | --- | --- |
| `title` | string, required | Up to 80 characters. Required on start, optional on updates. |
| `status` | string, required | Short state line, up to 60. |
| `detail` | string | Secondary line, up to 240. Nullable. |
| `progress` | number | 0 to 1 inclusive. Nullable. |
| `symbol` | enum | `terminal`, `code`, `build`, `success`, or `warning`. Defaults to `terminal`. |
| `accentColor` | string | Six-digit hex, `#RRGGBB`. Defaults to `#5ED8B7`. |
| `style` | enum | `standard`, `ring`, `hero`, `terminal`, or `steps`. Defaults to `standard` and selects the widget layout. Updates can switch it mid-flight. App builds that predate a style fall back to the standard layout. |
| `privacyMode` | enum | `standard` or `private`. Private replaces the start alert text with a generic line so the title and status stay off a locked screen. |
| `key` | string, start only | Your own alias, up to 100 characters, usable in place of the activity ID. A key becomes reusable once its activity ends. |
| `replace` | boolean, start only | End any Live Activity occupying a target device, and any of your own still holding the same `key`, before starting. Defaults to `false`. The response reports the displaced count as `replaced`. |
| `deviceIds` | string[], Pro | 1 to 50 device IDs. Omit to target every capable device. |

The five layouts, rendered with the same state:

- `standard` — Icon, title over status, trailing percent, linear progress bar. The default.
- `ring` — A determinate capacity gauge with the percent centered; no linear bar.
- `hero` — Status becomes the headline, the title demotes to an eyebrow, and the bar runs edge to edge along the bottom of the card.
- `terminal` — Monospaced prompt treatment: the status lowercased behind a prompt glyph, the detail as a comment line.
- `steps` — Progress quantized into five stage pips — phases, not percent.

### Expiry and staleness

`expiresInSeconds` accepts 60 to 28,800 and defaults to 28,800 — eight hours. Once an activity passes its expiry it is marked `expired` and stops accepting updates.

`staleAfterSeconds` accepts 0 to 28,800 and defaults to 14,400 — four hours. Past that deadline iOS treats the content as possibly out of date, but the card stays visible and updateable. Every update rolls the deadline forward from now, clamped to the expiry. An update that omits `staleAfterSeconds` reuses the previous window.

### One per device

A device can host one Hark Live Activity at a time. Starting another while one is still live on a target device returns `409`:

```json
{
  "ok": false,
  "error": "A Live Activity is already active on a target device",
  "code": "ACTIVE_ACTIVITY_CONFLICT",
  "activityId": "act_9Rk2wQpLm4Tz"
}
```

The `activityId` is included only when the blocking activity belongs to the same service, so you can update it instead of starting over. Branch on `code` rather than the message text.

To take the slot instead, pass `replace: true` in the start payload. Hark silently ends whatever occupies each target device — the old card is dismissed immediately, showing its last state — and then starts your activity. The success response reports how many activities were displaced as `replaced`; with nothing to displace the start behaves normally and `replaced` is `0`.

`replace` also displaces activities started by your other services or API tokens, so a stale card from another integration cannot deadlock a device, though a foreign `activityId` is never disclosed. When the start carries a `key`, your live activity holding that key is ended everywhere — even on devices the start does not target — so the key always transfers to the new run. The implicit ends are not billed against your notification allowance. Combined with reusable keys this makes `key` plus `replace: true` a fixed-key restart you can send on every run.

### Alerts and priority

A start carries an alert, so it may notify the user like a normal notification. Updates and ends carry no alert and are silent. Hark sends every Live Activity push at high APNs priority, which affects delivery speed only, not sound or haptics.

Live Activity operations count against the same per-minute and monthly limits as notifications, so a chatty progress loop consumes the same budget. Throttle to meaningful state changes.

> iOS also budgets push-to-start deliveries per app. Rapid successive starts to the same device can be silently suppressed: the start still reports `accepted`, but the device never registers an update token, so every later update and end fails with `MissingUpdateToken`. Space fresh starts out by a minute or so — or keep one activity alive and update it, which is cheaper and never hits the budget.

## harkctl CLI

`harkctl` wraps the agent API for terminals, scripts, and coding agents: one-shot notifications, questions with answers you can wait on, and Live Activities — no webhook URL required. It needs Node.js 22 or newer.

### Install and sign in

Run it straight from npm with `npx harkctl`, or install it globally with `npm install -g harkctl`. Signing in uses a browser device-authorization flow — no tokens on the command line, ever.

```bash
npx harkctl auth login
```

1. The CLI prints a short code and opens [hark.ryan.ceo](https://hark.ryan.ceo) in your browser.
2. Sign in and approve the requested scopes; every scope is shown before you approve.
3. Credentials are written to an OS config file with mode `0600`, and the CLI polls until the approval lands.

Each login appears under your dashboard's Services list as an agent connection, with its scopes, creation date, and last use. Revoking it there signs that agent out immediately. `harkctl auth status` shows the active connection; `harkctl auth logout` revokes and removes local credentials.

Use repeatable `--scope` flags to narrow access, `--client-name` to label the connection, and `--expires-in` (default `90d`) to bound its lifetime. For CI or self-hosted setups, `HARK_TOKEN` and `HARK_API_URL` environment variables override the config file.

### Create a webhook service

`harkctl services create` creates a persistent webhook endpoint for another tool or workflow. Its title, image, and tap URL become defaults, so the sender only needs to POST a `body`. The command prints the credential-bearing `webhookUrl` in its JSON response.

```bash
harkctl services create \
  --title "Release bot" \
  --image https://example.com/bot.png \
  --url https://ci.example.com/releases
```

```json
{
  "service": {
    "id": "svc_...",
    "title": "Release bot",
    "imageUrl": "https://example.com/bot.png"
  },
  "webhookUrl": "https://hark.ryan.ceo/hooks/hook_..."
}
```

Use `--stdin` to provide the same fields as JSON. `services list` lists existing services without emitting their webhook credentials. Creation requires `services:write`; if your CLI login predates that scope, sign in again and approve the updated permissions.

> Treat `webhookUrl` as a secret. Anyone who has it can send notifications through that service.

### Send a notification

`harkctl notify <body>` sends a one-shot push to every active iPhone on your account. Appearance is per call — the title acts as the sender name, and messages with the same title thread together like a service.

```bash
harkctl notify "Build 48 passed" \
  --title "CI" \
  --image https://github.com/github.png \
  --url https://ci.example.com/builds/48
```

| Flag | Type | Description |
| --- | --- | --- |
| `--title` | string | Sender name. Defaults to `Hark`. |
| `--image` | url | Public HTTPS avatar, same rules as the webhook `imageUrl`. |
| `--url` | url | Opened when the notification is tapped. |
| `--device` | id, repeatable | Target specific iPhones. Requires Hark Pro. |
| `--idempotency-key` | string | Safe retries: replays return the original result without a second push. |
| `--stdin` | boolean | Merge a JSON object from stdin under the explicit flags. |

> Sends from one connection share the webhook per-minute budgets — the requester counts like a service, the account window spans everything — and the same monthly notification allowance.

### Ask a question

`harkctl notify ask <prompt>` sends a push that elicits an answer. Pass exactly one response type: `--approval` (Approve/Deny), `--yes-no` (Yes/No), or `--text` (a short typed reply). The appearance flags from `notify` all apply.

```bash
harkctl notify ask "Deploy 8e7fc2a to production?" \
  --approval --title "Deploybot" --wait --timeout 15m
```

`--wait` blocks until the answer arrives or the timeout passes. `--poll` waits at most 20 seconds to catch an instant answer, for the case where someone is already looking at their phone. A timed-out poll or wait does not end the prompt — it stays answerable until it expires (default 15 minutes, `--expires-in` to change), and `harkctl interaction wait <id>` resumes waiting any time.

```json
{
  "interaction": {
    "id": "int_7MFuml-SqoUmpPLo",
    "kind": "reply",
    "status": "replied",
    "response": "ship it",
    "respondedAt": "2026-07-26T11:54:17.927Z"
  },
  "accepted": 1,
  "timedOut": false
}
```

### Drive a Live Activity

The `activity` commands drive the Activity API end to end. Address an activity by the returned ID or by your own `--key`, and pick a layout with `--style`.

```bash
harkctl activity start --key deploy --replace --style ring \
  --title "Deploy #184" --status "Building" --progress 0.1

harkctl activity update deploy --status "Testing" --progress 0.6 --if-sequence 0

harkctl activity end deploy --status "Shipped" --progress 1 --dismiss-after 45s
```

- `--replace` takes the device slot and the key, ending whatever blocks them, so a fixed-key start works on every run.
- `--if-sequence` rejects stale writes: the update only applies if the activity is still at that sequence.
- `--style` selects `standard`, `ring`, `hero`, `terminal`, or `steps`, and can change mid-flight on `update`.
- `activity get <id|key>` reads current state; `activity list` shows recent activities.

### Scripting and exit codes

Every successful command prints exactly one JSON object to stdout; diagnostics go to stderr. Exit codes make answers branchable without parsing:

- `0` — success, approved, yes, or replied
- `4` — timed out, canceled, or expired
- `5` — denied or no
- `7` — no device accepted the push
- `1` API error · `2` usage error · `3` authentication or scope error · `6` network error

```bash
if harkctl notify ask "Deploy to production?" --approval --wait --timeout 10m; then
  ./deploy.sh && harkctl notify "Deployed" --title "Deploybot"
else
  echo "Not approved" >&2
fi
```
