public api
API v2
Build your own clients, devices, and automations on PuppyClicker. v2 covers nearly everything the app can do.
Base URL: https://puppyclicker-api.boundfire.com/api/v2
The v1 endpoint (GET /api/v1/click?key=..., see the v1 reference) is unchanged and stays supported.
Concepts and terminology
- Puppy: an accepted friend that the key owner controls. The v2
puppiesnamespace contains only your accepted friends. - Action: something you send a puppy. A
click, amessage, anosc(OpenShock zap, vibrate, or sound), arest(the puppy's configured webhook button), anintegration(a delegated third-party button, for example Chaster add-time), or asequence(a custom action the puppy built and shared: a saved program of clicks, zaps, vibrations, beeps, and waits that runs on them). Which actions a puppy supports depends on what hardware and webhooks they have configured and what they have delegated to you. - Self-click: clicking yourself to ring your own devices.
- Self actions:
/puppies/self/actionstargets yourself, no friendship needed. Supportsclick,osc,rest, andsequence(your own sequences). - Key owner / acting user: the key is the identity. Every action runs as the key's owner.
Authentication
Every request needs an API key, created in the app under Settings → Public API Keys. Send it as a bearer token:
Authorization: Bearer pak_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
For the SSE stream you can pass the key as a query parameter instead, ?key=pak_..., since browser EventSource can't set request headers.
Errors
Every error is JSON with a human error message and a stable machine code:
{ "error": "Puppy not found", "code": "PUPPY_NOT_FOUND" }
| HTTP | code | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST | Body or query failed validation. error is the first validation problem. |
| 400 | TARGET_NOT_CONFIGURED | The puppy doesn't have that hardware or webhook set up (for example an osc action to a puppy with no OpenShock device). |
| 400 | UNSUPPORTED_SELF_ACTION | message or integration sent to /puppies/self/actions. These never work on yourself. Doesn't consume the action budget. |
| 401 | MISSING_API_KEY | No key in the header or query. |
| 401 | INVALID_API_KEY | Unknown key. |
| 401 | KEY_NOT_V2 | Key predates v2. Create a fresh key in Settings. |
| 403 | ACCOUNT_BANNED | The key owner's account is banned from PuppyClicker :c. |
| 404 | PUPPY_NOT_FOUND | No such puppy or you're not (yet) an accepted friend. The API never reveals which. |
| 404 | ACTION_NOT_FOUND | The rest/integration/sequence action id doesn't exist, is inactive, or isn't delegated/shared with you. |
| 409 | SEQUENCE_RUNNING | A sequence is already running on that puppy. One run at a time per puppy; wait for it to finish (see worstCaseMs). |
| 429 | RATE_LIMITED | Over budget; retry after retryAfter seconds (also in the Retry-After header). |
| 429 | STREAM_LIMIT | Too many concurrent SSE connections for your account. |
| 500 | INTERNAL | Server error. Please report on Discord. Safe to retry with backoff. |
| 502 | UPSTREAM_FAILED | The puppy's device, webhook, or integration failed to respond. The click is still recorded. |
Rate limits
Two per-key budgets over a rolling 5-minute window, plus a per-account cap on stream connections:
| Budget | Free | Supporter |
|---|---|---|
| Actions (every POST) | 20 | 200 |
Reads (every GET except /stream) | 240 | 240 |
| Concurrent SSE stream connections (per account) | 1 | 5 |
- The action budget scales with the owner's support tier; a freshly upgraded account may stay on the free limit for a few minutes until its cached tier refreshes. Reads are the same for everyone.
- There is a global per-IP limit of 300 requests / 10 minutes applied before key lookup, so it covers invalid-key traffic too. That's what stops key guessing.
- On
429please honour theRetry-Afterheader (seconds), especially if other people will run your app.
CORS
/api/v2 allows any origin.
Endpoints
GET /me
The key owner's own profile. Costs 1 read.
{
"id": "3f1c8a2e-5b04-4d97-9a61-2e7c0f8b41d3",
"username": "puppy",
"poundUsername": "puppyyyy123",
"pronouns": "they/them",
"terminology": "puppy",
"createdAt": "2026-01-05T10:20:30.000Z"
}
poundUsername, pronouns, and terminology may be null.
GET /puppies
Your accepted friends, each with a capability summary. Ordered by your saved friend order. Costs 1 read.
{
"puppies": [
{
"id": "9c4d7b10-2f83-4e6a-b5c1-7d0928ea3f45",
"username": "goodboy",
"poundUsername": null,
"pronouns": null,
"terminology": "puppy",
"profilePictureUrl": null,
"capabilities": {
"click": true,
"message": true,
"osc": true,
"rest": false,
"integrations": false,
"sequences": false
}
}
]
}
capabilities tells you which action types will work for that puppy right now:
| Capability | True when |
|---|---|
click | Always. |
message | Always. |
osc | The puppy has a validated OpenShock device configured. |
rest | The puppy has at least one active REST/webhook button. |
integrations | The puppy has delegated at least one integration button to owners. |
sequences | The puppy has shared at least one custom action sequence with owners. |
Use GET /puppies/:id/actions for the full parameter metadata behind these flags.
GET /puppies/:id/actions
Everything you can send this puppy, with parameter metadata per action kind. Costs 1 read. Returns 404 PUPPY_NOT_FOUND if :id isn't one of your accepted friends.
Replace :id with self to get self actions: click always, plus osc (your own device and caps), rest (your own buttons), and sequence (your own sequences) when configured. message and integration are never listed on self. Your own user id does not work here, only self.
{
"actions": [
{
"type": "click",
"params": {
"intensity": { "min": 0, "max": 100, "optional": true },
"duration": { "min": 0, "max": 30000, "optional": true },
"message": { "maxLength": 150, "optional": true }
}
},
{
"type": "message",
"params": { "message": { "minLength": 1, "maxLength": 150, "required": true } }
},
{
"type": "osc",
"subtypes": ["Shock", "Vibrate", "Sound", "Stop"],
"maxIntensity": 60,
"maxDuration": 5000,
"online": true,
"params": {
"intensity": { "min": 0, "max": 60, "required": true },
"duration": { "min": 300, "max": 5000, "required": true }
}
},
{
"type": "rest",
"id": "b21e6f4a-8c37-4d52-9e08-1a4b6c3d5f72",
"name": "Open the door",
"description": "…",
"icon": "door",
"variables": [ /* config-defined variable schema */ ]
},
{
"type": "integration",
"id": "7e05a9c3-6b18-4f2d-8a71-c93f0d24b6e8",
"provider": "chaster",
"providerLabel": "Chaster",
"action": "add_time",
"label": "Add 10 min",
"icon": "lock",
"params": { /* provider/action param schema */ }
},
{
"type": "sequence",
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Warmup",
"worstCaseMs": 13000,
"steps": ["vibrate", "wait", "shock"]
}
]
}
Per kind:
click: always present. Optionalintensity(0-100),duration(0-30000 ms),message(up to 150 chars).message: always present. Requiredmessage(1-150 chars). Officially unsupported, but it works fine in the client. Listed for posterity.osc: present only when the puppy has a validated OpenShock device. Carries the puppy's own safety caps (maxIntensity,maxDuration) reflected intoparams.max, plusonline(whether the device is reachable right now). The server also clamps commands to these caps regardless of what you send.subtypeselects the effect.rest: one entry per active REST/webhook button.idis the config id to pass back, or the literal"legacy"for older single-endpoint setups.variablesis the button's own variable schema.integration: one entry per delegated integration button. Passidback;paramsdescribes any runtime parameters the action accepts (for example how much time to add).sequence: one entry per custom action the puppy shared with owners (onself: your own).stepsis a summary of the step kinds in order (click,shock,vibrate,beep,wait,integration);worstCaseMsis the longest the run can take. No parameters. The puppy authored the whole program.
POST /puppies/:id/actions
Send an action to a puppy. Consumes 1 action from the action budget. The body is a JSON object discriminated on type.
| type | Fields |
|---|---|
click | intensity? 0-100, duration? 0-30000 (ms), message? up to 150 chars |
message | message 1-150 chars (required) |
osc | subtype Shock | Vibrate | Sound | Stop, intensity 0-100, duration 300-30000 (ms) |
rest | id (config id, or "legacy"), variables? (object of number | bool | string), message? up to 150 chars |
integration | id (button UUID), params? (object) |
sequence | id (sequence UUID) |
Click intensity is a no-op for now.
{ "type": "click", "intensity": 50, "duration": 800, "message": "boop" }
{ "type": "message", "message": "good puppy" }
{ "type": "osc", "subtype": "Vibrate", "intensity": 30, "duration": 1000 }
{ "type": "rest", "id": "b21e6f4a-8c37-4d52-9e08-1a4b6c3d5f72", "variables": { "speed": 3 }, "message": "hi" }
{ "type": "integration", "id": "7e05a9c3-6b18-4f2d-8a71-c93f0d24b6e8", "params": { "addSeconds": 600 } }
{ "type": "sequence", "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }
Success response:
{ "success": true, "dndSuppressed": false, "message": "…" }
dndSuppressedis present on click, message, osc, rest, and integration results. It'strueonly when the puppy is in Do Not Disturb and shares their DND status; otherwise it's alwaysfalse. A private DND state is never revealed to you. See Behaviour and semantics.messageechoes any response text the upstream REST or integration returned; absent for plain clicks and messages.sequenceresults also carryrunIdandstepCount. The whole run costs 1 action, however many steps it has. The response returns as soon as the run starts. The steps then run server side, waits included, so a run can outlive your request by up toworstCaseMs.
curl -X POST \
-H "Authorization: Bearer pak_..." \
-H "Content-Type: application/json" \
-d '{"type":"click","message":"boop"}' \
https://puppyclicker-api.boundfire.com/api/v2/puppies/<puppyId>/actions
Failure modes worth handling: 404 PUPPY_NOT_FOUND (not your friend), 400 TARGET_NOT_CONFIGURED (for example osc to a puppy with no device), 404 ACTION_NOT_FOUND (bad, inactive, or non-delegated rest/integration/sequence id), 409 SEQUENCE_RUNNING (a sequence is already running on that puppy), 502 UPSTREAM_FAILED (their device or webhook errored, but the click is still recorded), 429 RATE_LIMITED.
Self actions
POST to /puppies/self/actions to act on yourself. Same bodies, same budget. Differences from a friend target:
clickbehaves likePOST /clicks/self: it's recorded as typeapiunder your key's name, withintensityanddurationsaved if you sent them.oscfires your own device under your own safety caps. AStopalways reaches the device, even during DND, so you can always halt a running command.resttriggers your own buttons (idfrom the self action list, or"legacy").sequenceruns one of your own sequences on yourself (they don't need to be shared with owners).messageandintegrationreturn400 UNSUPPORTED_SELF_ACTION.dndSuppressedreports your own DND state. Quiet hours suppress your own zaps and pushes too; the click is still recorded.
GET /clicks/received
Cursor-paginated feed of clicks you received, newest first. Costs 1 read.
Query params:
| Param | Notes |
|---|---|
take | 1-50, default 30. |
cursor | The previous page's nextCursor. Omit for the first page. |
{
"items": [
{
"id": "d84f2b67-0a95-4c31-8be2-5f7a19c04d6b",
"type": "click",
"source": null,
"message": "boop",
"intensity": null,
"duration": null,
"timestamp": "2026-07-13T12:00:00.000Z",
"senderId": "3f1c8a2e-5b04-4d97-9a61-2e7c0f8b41d3",
"senderUsername": "owner",
"senderPoundUsername": null,
"hidden": false
}
],
"nextCursor": "2026-07-13T12:00:00.000Z_d84f2b67-0a95-4c31-8be2-5f7a19c04d6b"
}
typeis the click kind:click,zap(OpenShock),api(API / REST / integration / self), and so on.sourceis where it originated (nullfor a direct friend click, or values likepound/landingfor social and landing-page clicks).hidden: truerows come from a user you've blocked or who blocked you. TheirsenderId/senderUsername/senderPoundUsernamearenull, but the click itself is still listed.nextCursorisnullwhen there are no more rows. To page, pass it back verbatim ascursor.
POST /clicks/self
Records a self-click and rings your own devices, same as the v1 click. Consumes 1 action.
Body (optional):
{ "message": "optional, up to 150 chars" }
Response:
{ "success": true, "dndSuppressed": false }
Here dndSuppressed reports your own DND state, since the recipient is you. true means your quiet hours suppressed the push. The click was still recorded and streamed.
curl -X POST -H "Authorization: Bearer pak_..." \
-H "Content-Type: application/json" -d '{"message":"coffee"}' \
https://puppyclicker-api.boundfire.com/api/v2/clicks/self
Same effect as GET /api/v1/click. v1 isn't going anywhere.
GET /stream
A Server-Sent Events stream of your real-time alerts. These are the same events the desktop app receives (incoming clicks, messages, self-clicks, Pound social events), each with dndSuppressed flags.
Send the key as a bearer header, or as ?key= for browser EventSource, which can't set headers:
# In a browser you'd use EventSource("…/api/v2/stream?key=pak_…").
# From a shell with a header:
curl -N -H "Authorization: Bearer pak_..." \
https://puppyclicker-api.boundfire.com/api/v2/stream
# Or with the query key:
curl -N "https://puppyclicker-api.boundfire.com/api/v2/stream?key=pak_..."
Frame types on the wire:
ready, sent once on connect:event: ready data: {"connectedAt":"2026-07-13T12:00:00.000Z"}- Keepalive comments every 25 seconds (SSE comment lines, ignore them):
: keepalive alertevents, your actual notifications:event: alert data: {"id":"d84f2b67-0a95-4c31-8be2-5f7a19c04d6b","kind":"click","senderUsername":"owner","body":"boop","timestamp":"2026-07-13T12:00:00.000Z","dndSuppressed":false}
Alert data fields. New ones may be added over time, so ignore unknown keys:
| Field | Notes |
|---|---|
id | The click or message id. |
kind | click | message | comment | reaction_emoji | reaction_button. |
senderUsername | Display name of the sender (or the key name for self and API clicks). |
body | Message text, or *click* for a bare click. |
timestamp | ISO 8601. |
duration | Press-and-hold duration (ms), on click alerts that carry one. |
source | pound | landing for social and landing-page clicks; absent for friend clicks. |
dndSuppressed | true when you're in DND. The event is delivered so live views update, but clients should skip the native notification and sound. |
test | true for self-test clicks. |
Concurrency is capped per account (1 free / 5 supporter).
Behaviour and semantics
v2 actions behave the same as the app. A few things to know:
- Friendship. Every action needs an accepted friendship; otherwise
404 PUPPY_NOT_FOUND. - Do Not Disturb suppresses delivery. During the recipient's quiet hours the server drops the push and any physical trigger (OpenShock), but still records the click and still fires the real-time alert, flagged.
dndSuppressedprivacy. On actions you send to a puppy, the flag istrueonly when the puppy both is in DND and shares their DND status; otherwise alwaysfalse. OnPOST /clicks/selfit reflects your own state truthfully.- OpenShock clamping. The server clamps
oscintensity and duration to the target puppy's safety caps (maxIntensity,maxDuration), regardless of what you request. Still, send values within their caps so your app shows users what will actually happen. - REST variables. The server clamps them to the button's configured limits before the webhook fires.
- Sequences run on their author, server side. One run per puppy at a time (
409 SEQUENCE_RUNNING). The server clamps every device step to the puppy's current safety caps, and before each step re-checks that the run is still allowed (friendship, blocks, Do Not Disturb, device still configured). If not, it quietly stops the rest. AnoscStopcuts the puppy's current device step but not the rest of the run; only the puppy (or the app) can stop the run itself. - Notifications are rate limited per recipient, separately from your request budget. REST pings, for example, are capped at about 30 per 5 minutes per recipient.