Hark docs
Hark is an inbox on your phone for your scripts and AI agents. Anything that can make an HTTP request can send you a card, ask you a question and wait for the answer, show live progress, or ring an alarm. You answer with a tap, often straight from the notification, and your code gets the answer back.
This is the reference for Hark Cloud at https://api.harkapp.io. Everything is JSON over HTTPS, times are Unix epoch seconds, and IDs are strings.
Quick start#
- Install the Hark app and sign in with your email, or sign in at app.harkapp.io.
- Open Settings > API keys, make a key and copy it. It starts with
hk_. - Send yourself a card:
bashexport HARK_TOKEN=hk_...
curl -H "Authorization: Bearer $HARK_TOKEN" \
-d '{"title": "Backup finished", "body": "412 GB in 38 minutes"}' \
https://api.harkapp.io/api/messages
Ask a question and block until you answer (or 10 minutes pass):
bashcurl -H "Authorization: Bearer $HARK_TOKEN" \
-d '{"agent": "deploy", "title": "Ship v2.14?", "choices": ["Deploy", "Cancel"], "timeout": "10m"}' \
"https://api.harkapp.io/api/ask?wait=600"
The response is the question card. request.response.choice is "deploy" or "cancel", or the question has request.status: "expired" if nobody answered.
Set a timer:
bashcurl -H "Authorization: Bearer $HARK_TOKEN" \
-d '{"in": "25m", "label": "Pomodoro"}' https://api.harkapp.io/api/alarms
From PowerShell, use Invoke-RestMethod so the JSON isn't mangled by quoting:
powershell$h = @{ Authorization = "Bearer $env:HARK_TOKEN" }
Invoke-RestMethod -Method Post https://api.harkapp.io/api/messages -Headers $h `
-ContentType "application/json" -Body (@{ title = "Hello"; body = "From PowerShell" } | ConvertTo-Json)
From Python, use the one-file client (section Python client):
pythonfrom hark import Hark # curl -O https://docs.harkapp.io/hark.py
h = Hark(token="hk_...", agent="deploy", name="Deploy bot", avatar="🚀")
h.send("Build passed", title="aux-web v2.14", state="ok")
ans = h.ask("Ship v2.14?", choices=["Deploy", "Cancel"], timeout="10m", wait=600)
if ans and ans["choice"] == "deploy":
...
Authentication#
Every endpoint except GET /api/health and GET /files/... needs a token. Send it any of these ways:
Authorization: Bearer hk_...
X-Hark-Token: hk_...
?token=hk_... (query string; for EventSource, WebSockets and quick tests)
There are two kinds of token, both shaped hk_<account>_<secret>:
- API keys are for scripts and agents. Make, name and revoke them in Settings > API keys. Revoking takes effect immediately.
- Sessions are what the app and the web inbox get when you sign in. Only a session can make API keys or delete the account.
Keep keys secret: anyone with one can post to your phone and read your inbox. Don't put them in code you share, and use one key per machine or agent so you can revoke them separately.
Browsers can call the API directly: every response allows cross-origin requests.
Messages (cards)#
POST /api/messages#
Creates a card (201). If key matches an existing card from the same agent, updates that card instead (200). See Live cards.
| field | type | notes |
|---|---|---|
agent | string | Who is posting. Lowercase a-z0-9._-, max 40; other text is turned into that form. Default api. Created on first use, and each agent gets its own thread. |
agent_name, avatar, color | string | Display info for the agent. avatar is an emoji or one or two characters; color is a CSS color. |
title | string | Bold heading, up to 300 characters. |
body | string | Markdown-ish text: bold, italic, code, fenced code, lists, quotes, links, headings. Aliases: text, message. Up to 50,000 characters. |
blocks | array | Rich content. See Blocks. |
image | string | An https:// URL, a /files/... path from an upload, or a data:image/...;base64,... URL (stored for you). Shown in the card and as the notification's picture. |
html | string | Your own HTML in a sandboxed iframe (scripts allowed, no access to Hark). Up to 500 KB. Sizes itself to its content. |
html_height | int | Fixed iframe height in pixels (40 to 2000) instead of auto-size. |
json | any | Shown as a collapsible JSON tree. |
links | array | ["https://..."] or [{"url", "label"}], shown as chips. The first link becomes an "Open link" notification button. link (one) also works. |
url | string | The card's main link. |
state | string | idle, running, waiting, ok, warn, error or info. Shows a status pill; ok and error also tint the card. |
progress | number | 0 to 1 (or 0 to 100). A progress bar that glides between updates. |
priority | string | min, low, default, high or max: how loudly the phone notifies. Questions default to high. |
notify | bool | false puts the card in the inbox without a notification. Default true. |
pin | bool | Put the card on the pinned board at the top of the feed. |
tags | string[] | Up to 10 short tags. |
key | string | Live-card key. |
reply_to | string | ID of the message this replies to. |
request, choices, fields, timeout, deadline, on_expire, allow_text, placeholder | Make the card a question. See Questions. |
A card needs at least one of title, body, blocks, html, image, json, links or request. The body can also be form fields or plain text: curl -d "Backup done" ... posts a card whose body is Backup done.
The response is the stored message:
json{
"id": "m_3f1c0a9b2e4d", "seq": 118, "agent": "deploy", "dir": "in", "key": null,
"created": 1790000000.12, "updated": 1790000000.12, "seen": null, "pinned": false, "dismissed": false,
"title": "Ship v2.14?", "body": "...", "priority": "high", "notify": true,
"request": { "...": "see Questions" }
}
dir is in for cards posted to you and out for things you typed. seq is a strictly increasing cursor across the whole account.
POST /api/notify#
The same as POST /api/messages, but query-string and form fields are merged into the body, for tools that can only call a URL:
bashcurl -X POST "https://api.harkapp.io/api/notify?token=$HARK_TOKEN&title=Doorbell&message=Someone%20is%20at%20the%20door&priority=high"
GET /api/messages#
The newest page, returned oldest to newest. Returns {"messages": [...], "has_more": bool}.
| query | notes |
|---|---|
limit | 1 to 500, default 60. |
before | Only messages with seq below this, for paging backwards. |
agent | One agent's thread. |
only | waiting (open questions), pinned or unseen. |
include_dismissed | 1 to include cards you swiped away. |
GET, PATCH, DELETE /api/messages/{id}#
PATCH takes any content field from the table above, plus pinned, dismissed and seen: true. Changing an unanswered card updates its notification quietly; send "notify": true to buzz again. Marking a card seen, dismissing it or deleting it removes its notification from your phone.
POST /api/seen#
{"ids": ["m_...", ...]} or {"all": true}. Returns {"seen": [ids that changed]}.
POST /api/purge#
Permanently deletes every dismissed card. Returns {"deleted": n}.
Blocks#
blocks is a list of objects with a type. A bare string becomes a text block, and unknown types are shown as JSON instead of being rejected. Up to 150 blocks and 1 MB; extra blocks are dropped.
| type | fields | shows as |
|---|---|---|
heading | text | A small bold heading. |
text | text | Markdown paragraphs. |
quote | text | A quoted line. |
kv | items: {"k": "v"}, [["k", "v"]] or [{"label", "value"}] | A two-column list. |
metric | label, value, delta?, tone? | A big number. Metrics in a row sit side by side. |
status | text, tone (ok, warn, error, info, idle) | A colored status pill. |
progress | value (0 to 1), label? | A labeled progress bar. |
list | items, ordered? | A bulleted or numbered list. |
code | text (or lines), lang? | Monospace with a copy button. |
log | text (or lines) | Like code, keeps the last 400 lines and colors error, warning and ok lines. |
image | url (or src, or base64 + content_type), caption? | An image; tap to zoom. |
link | url, label? | A link chip. |
divider | A thin rule. | |
json | data | A collapsible JSON tree. |
json{"agent": "nuc", "title": "NUC health", "blocks": [
{"type": "metric", "label": "CPU", "value": "23%", "tone": "ok"},
{"type": "metric", "label": "Disk", "value": "81%", "delta": "+4%", "tone": "warn"},
{"type": "status", "text": "All services up", "tone": "ok"},
{"type": "kv", "items": {"uptime": "41d", "load": "0.42"}},
{"type": "log", "lines": ["backup ok", "warning: slow disk"]}
]}
Live cards#
Post again with the same agent and key and the existing card updates in place instead of a new one appearing. Use it for builds, downloads, renders, anything with progress. Only the fields you send change. The first post notifies (unless notify is false); later updates refresh the same notification quietly, at most every 2 seconds.
bashfor p in 10 40 75 100; do
curl -s -H "Authorization: Bearer $HARK_TOKEN" \
-d "{\"agent\": \"render\", \"key\": \"job-42\", \"title\": \"Rendering\", \"progress\": $p, \"state\": \"running\", \"notify\": false}" \
https://api.harkapp.io/api/messages > /dev/null
sleep 2
done
curl -s -H "Authorization: Bearer $HARK_TOKEN" \
-d '{"agent": "render", "key": "job-42", "state": "ok", "body": "Done"}' https://api.harkapp.io/api/messages
Questions#
Any card can ask something. Top-level choices, fields, timeout, deadline, on_expire, allow_text and placeholder are shorthand for a request object:
json{
"agent": "deploy",
"title": "Ship aux.fan v2.14?",
"body": "Build passed. 4 migrations pending.",
"request": {
"choices": [
{"label": "Deploy", "style": "primary"},
{"label": "Inspect"},
{"id": "rollback", "label": "Roll back", "style": "danger", "confirm": "hold"}
],
"timeout": "30m",
"on_expire": "inspect"
}
}
| request field | notes |
|---|---|
choices | Up to 8 buttons: strings or {id?, label, style?, confirm?}. id defaults to a slug of the label ("Roll back" becomes roll-back). style is default, primary or danger. confirm: "hold" needs a press and hold in the app and is never offered on the notification. |
fields | Up to 20 inputs: {name, label?, type?, required?, placeholder?, value?, options?}. type is text, textarea, number, select (needs options), toggle, date or time. |
allow_text | A free-text answer box. On by default when there are no choices and no fields. |
placeholder | Placeholder for the text box. |
timeout | How long until it expires: 90, "90s", "10m", "1h30m". Or deadline: an absolute time (epoch, ISO 8601, or "17:30" in your time zone). |
on_expire | A choice id to record automatically when it expires, with via: "timeout". |
The first three choices without confirm: "hold" appear as buttons on the notification, so most questions can be answered without opening the app.
Answers#
request.status starts open and becomes answered, expired or cancelled. Once answered, or expired with on_expire:
json"response": {
"choice": "deploy", "label": "Deploy",
"fields": {"wake": "06:45", "gym": true},
"text": "ship it",
"at": 1790000123.4,
"via": "notification"
}
via is app, notification, api, cli or timeout.
Waiting for the answer#
POST /api/ask?wait=SECONDSposts and blocks until the question is answered, expires or is cancelled, up to 3600 seconds. A plain{"title": "..."}with no choices or fields becomes a free-text question.GET /api/messages/{id}/wait?timeout=60long-polls an existing question (up to 3600 seconds) and returns the message as soon as it isn't open. If it times out, it returns the still-open message: loop untilrequest.statusisn'topen.- Or give the agent a webhook and get a POST, or watch live updates.
Answering and withdrawing from code#
POST /api/messages/{id}/respondwith{"choice": "deploy"}(an id or a label, any case),{"fields": {...}},{"text": "..."}or a mix. Returns 409 if the question is no longer open.POST /api/messages/{id}/cancelwithdraws an open question (cancelled).
Webhooks#
Give an agent a webhook and Hark POSTs to it when you answer one of its questions, when one expires, and when you send the agent a chat message:
bashcurl -X PUT -H "Authorization: Bearer $HARK_TOKEN" \
-d '{"name": "Deploy bot", "avatar": "🚀", "webhook": "https://example.com/hark", "secret": "s3cret"}' \
https://api.harkapp.io/api/agents/deploy
The payload:
json{"event": "response", "agent": "deploy", "message": {"...": "the full message"}, "response": {"choice": "deploy"}, "sent_at": 1790000123.5}
event is response, expired or message. Headers: X-Hark-Event, and when the agent has a secret, X-Hark-Signature: sha256=<hex HMAC-SHA256 of the raw body>. Check it like this:
pythonimport hmac, hashlib
ok = hmac.compare_digest(sig, "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest())
A failed delivery is retried after 2 and 8 seconds. The outcome is saved on the message as delivery: {"ok", "at", "event", "error"} and shown on the card, so you can see in the app whether your agent got the answer.
Chat#
You can type to an agent in its thread in the app. That creates an out message in the agent's inbox.
- Agents with a webhook get an
event: "message"POST. - Agents without one long-poll:
GET /api/agents/{id}/inbox?after=<seq>&wait=30returns{"messages": [...], "cursor": <seq>}with the messages you sent afterafter, waiting up towaitseconds (max 300) for the first one. Passcursorback as the nextafter; start withafter=0&wait=0to catch up.
Reply by posting an ordinary card with the same agent. POST /api/chat {"agent": "deploy", "text": "status?"} sends as you; agent: "me" is a note to yourself.
Agents#
Agents are created the first time something posts as them. Two are built in: me (your notes) and hark (system messages and alarms).
GET /api/agents: every agent withunread,waitingand itslastmessage, most recently active first.GET /api/agents/{id}PUTorPATCH /api/agents/{id}:name,avatar,color,webhook,secret,muted. Muted agents still post cards but never notify.DELETE /api/agents/{id}/messages: clears the thread. Deletes every card from that agent, takes their notifications off your phones, and keeps the agent and its settings. Works formeandharktoo. Returns{"deleted": n}.DELETE /api/agents/{id}: deletes the agent, its settings and all its cards. Not allowed formeandhark. The agent comes back if it posts again.
In the app, press and hold a thread to clear or delete it.
Alarms and timers#
Alarms ring on your phone full screen, on the phone's own clock, so they work offline, with the screen off and in battery saver. Hark keeps the schedule; the app syncs it.
POST /api/alarms#
| field | notes |
|---|---|
in | A timer: "25m", "1h30m", 90. |
at | An alarm time: "07:30", "7am", "7:30pm", "tomorrow 9", ISO 8601 or epoch. A bare time means its next occurrence in your time zone. Aliases: time, when. |
label | Shown on the ring screen and the notification. |
repeat | daily, weekdays, weekends, hourly, "mon,wed,fri", ["sat", "sun"], "every 2h" or {"every": "90m"}. |
say | true speaks the label when it rings, or a string to speak instead. |
vibrate | Default true. |
ring_seconds, snooze_minutes | Per-alarm overrides of the account settings. |
webhook | A URL that gets {"event": "alarm.fired", "alarm": {...}} when it rings. |
It returns the alarm:
json{"id": "a_91be2c0d77aa", "at": 1790006400.0, "state": "scheduled", "kind": "alarm", "label": "Wake up",
"repeat": "weekdays", "repeat_text": "Weekdays", "vibrate": true, "created": 1790000000.0, "updated": 1790000000.0,
"fired": null, "message_id": null}
state is scheduled, ringing, snoozed, done, missed or cancelled. If nobody stops a ringing alarm within ring_seconds (5 minutes by default) it becomes missed. Repeating alarms go back to scheduled at their next time after they're stopped or missed.
Other alarm endpoints#
GET /api/alarms(?active=1for only scheduled, snoozed and ringing). Each alarm carriesupcoming: its next three repeat times.GET /api/alarms/{id}PATCH /api/alarms/{id}: any field above, plusenabled: true/false. Turning a finished alarm back on schedules its next time.DELETE /api/alarms/{id}POST /api/alarms/{id}/ack: stop a ringing alarm.POST /api/alarms/{id}/snooze{"minutes": 5}(default: your snooze setting).
Times like 07:30 use the account's time zone, which the app sets when you sign in. See it with GET /api/config (tz) and change it with PATCH /api/account {"tz": "America/New_York"}.
Files#
POST /api/files?name=shot.png with the raw bytes as the body and a Content-Type header. Returns {"url": "/files/<account>/<name>", "size": n, "type": "image/png"}. Use the url in image or in image blocks. Up to 25 MB.
bashcurl -H "Authorization: Bearer $HARK_TOKEN" -H "Content-Type: image/png" \
--data-binary @shot.png "https://api.harkapp.io/api/files?name=shot.png"
GET /files/... needs no token (the names can't be guessed), so notifications and the app can load images directly. For one-off images, a data:image/png;base64,... URL in image is simpler: Hark stores it for you.
Live updates#
Every change in your inbox is pushed to connected clients as it happens, the same events the app uses.
- WebSocket:
wss://api.harkapp.io/api/ws?token=.... Each frame is{"event": "...", "data": {...}}. Sendpingand you getpong. - Server-sent events:
GET /api/stream?token=..., with a comment ping every 15 seconds.
| event | data |
|---|---|
hello | {version, time, unread, waiting, total} on every connect. |
message | {op: "new" | "update" | "delete", message} |
alarm | The alarm, or {id, deleted: true}. |
agent | The agent, {id, deleted: true}, or the agent with cleared: true when its thread was cleared. |
seen | {ids: [...]} |
resync | Something changed in bulk (a purge); fetch again. |
After a reconnect, fetch what you need again: events sent while you were away aren't replayed.
Account and settings#
GET /api/account:{id, email, tz, created, usage: {month, messages, limit}}.PATCH /api/account{"tz": "Europe/London"}GET /api/config: your settings, includingtz,notify,snooze_minutesandalarm_ring_seconds.PATCH /api/config:notify(notifications on or off for the whole account),snooze_minutes,alarm_ring_seconds,tz.GET /api/info: unread and waiting counts, total messages, live client count.POST /api/test-notification: sends a question with two buttons to your phones, to check the whole path.GET /api/keys: your API keys and sessions.POST /api/keys{"name"}makes a key and returns it once, intoken(needs a session).DELETE /api/keys/{id}revokes one.POST /api/auth/logout: revokes the token it's called with.DELETE /api/account{"confirm": "delete my account"}: erases everything (needs a session).
The app registers itself for push with POST /api/devices. GET /api/devices lists your phones, and POST /api/devices/{id}/test sends one a test push.
Errors and limits#
Errors are {"error": "a readable message"} with a fitting status:
| status | meaning |
|---|---|
| 400 | Bad input; the message says what's wrong. |
| 401 | Missing or wrong token. |
| 403 | The token can't do this (an API key where a session is needed). |
| 404 | Not found. |
| 405 | Wrong method for this path. |
| 409 | The question is no longer open. |
| 413 | Too large. |
| 429 | Over a limit: slow down, or wait for next month. |
Limits: 25,000 new cards a month per account, and 20 requests a second per key with bursts of 60. Request bodies up to 4 MB, uploads 25 MB, body 50,000 characters, html 500 KB, blocks 150 and 1 MB, 8 choices, 20 fields, 12 links.
Python client#
hark.py is one file using only the standard library. Download it next to your script:
bashcurl -O https://docs.harkapp.io/hark.py
pythonfrom hark import Hark
# Token from the argument or $HARK_TOKEN; URL from the argument, $HARK_URL, or Hark Cloud for hk_ keys.
h = Hark(token="hk_...", agent="nuc", name="NUC", avatar="â—Ž")
h.send("Disk at 81%", title="NUC health", state="warn")
for pct in (0.2, 0.6, 1.0): # a live card: same key, same card
h.card("backup", title="Backing up", progress=pct, state="running", notify=False)
ans = h.ask("Prune old snapshots?", choices=["Prune", "Skip"], timeout="10m", wait=600)
if ans and ans["choice"] == "prune":
...
h.send(title="Screenshot", image=h.image("shot.png")["url"])
h.alarm("25m", "Pomodoro")
h.alarm("07:30", "Wake up", repeat="weekdays", say=True)
def on_message(m): # you typed to this agent in the app
h.send(f"you said: {m['body']}", reply_to=m["id"])
h.listen(on_message)
ask(..., wait=N) returns the response (choice, label, fields, text, via, at) or None if nobody answered in time. Without wait it returns the message; call h.wait(message_id, timeout) later. Errors raise HarkError with status and message.
Self-hosting#
The same API runs on your own hardware: the Hark server is a small Python program with no dependencies that runs on a Raspberry Pi, a VPS, a NAS or an Android phone. Point the app at it with Use my own server instead on the sign-in screen. Everything in these docs applies, except the account, keys and limits sections; a self-hosted server has one token.