public api
API v2
Build your own clients, devices, and automations on PuppyClicker. V2 allows you almost full access to Puppyclicker.
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), or anintegration(a delegated third-party button, for example Chaster add-time). 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.
- Key owner / acting user: The key is the identity: every action is performed 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 (browser EventSource cannot set request headers) the key may instead be passed as a query parameter: ?key=pak_....
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). |
| 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 action id doesn't exist, is inactive, or isn't delegated to you. |
| 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
Three independent 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 (anti-enumeration).
- On
429please honour theRetry-Afterheader (seconds), especially if you are making an app others use.
CORS
/api/v2 allows any origin.
Endpoints
GET /me
The key owner's own profile. Costs 1 read.
{
"id": "usr_…",
"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": "usr_…",
"username": "goodboy",
"poundUsername": null,
"pronouns": null,
"terminology": "puppy",
"profilePictureUrl": null,
"capabilities": {
"click": true,
"message": true,
"osc": true,
"rest": false,
"integrations": 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. |
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.
{
"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": "cfg_…",
"name": "Open the door",
"description": "…",
"icon": "door",
"variables": [ /* config-defined variable schema */ ]
},
{
"type": "integration",
"id": "btn_…",
"provider": "chaster",
"providerLabel": "Chaster",
"action": "add_time",
"label": "Add 10 min",
"icon": "lock",
"params": { /* provider/action param schema */ }
}
]
}
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, should work fine in client, Here 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 currently reachable). Commands are also clamped server side 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).
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) |
{ "type": "click", "intensity": 50, "duration": 800, "message": "boop" }
{ "type": "message", "message": "good puppy" }
{ "type": "osc", "subtype": "Vibrate", "intensity": 30, "duration": 1000 }
{ "type": "rest", "id": "cfg_abc", "variables": { "speed": 3 }, "message": "hi" }
{ "type": "integration", "id": "btn_abc", "params": { "addSeconds": 600 } }
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(their private DND state is never leaked to you). See Behaviour and semantics.messageechoes any response text the upstream REST or integration returned; absent for plain clicks and messages.
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 id), 502 UPSTREAM_FAILED (their device or webhook errored, but the click is still recorded), 429 RATE_LIMITED.
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": "clk_…",
"type": "click",
"source": null,
"message": "boop",
"intensity": null,
"duration": null,
"timestamp": "2026-07-13T12:00:00.000Z",
"senderId": "usr_…",
"senderUsername": "owner",
"senderPoundUsername": null,
"hidden": false
}
],
"nextCursor": "2026-07-13T12:00:00.000Z_clk_…"
}
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
v1-click parity: records a self-click and rings your own devices. Consumes 1 action.
Body (optional):
{ "message": "optional, up to 150 chars" }
Response:
{ "success": true, "dndSuppressed": false }
Here dndSuppressed reflects your own DND state (the recipient is you, so it's not a privacy leak): true means your quiet hours suppressed the push, though 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
This is the v2 equivalent of GET /api/v1/click: same effect. V1 will continue to be supported in perpetutity though.
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. Does not consume the read budget; instead it's governed by the per-account concurrent-connection cap.
Auth accepts either the bearer header or ?key= (the latter 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":"clk_…","kind":"click","senderUsername":"owner","body":"boop","timestamp":"2026-07-13T12:00:00.000Z","dndSuppressed":false}
Alert data fields (all additive, 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); over-cap connects get 429 STREAM_LIMIT. Reconnect with exponential backoff.
Behaviour and semantics
v2 actions behaviour matches the app precisely. Here are a few quirks:
- Friendship: Every action requires an accepted friendship; otherwise
404 PUPPY_NOT_FOUND. - Do Not Disturb suppresses delivery. During the recipient's quiet hours the push and any physical trigger (OpenShock) are suppressed, but the click is still recorded and the real-time alert still fires (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.
oscintensity and duration are clamped server side to the target puppy's configured safety caps (maxIntensity,maxDuration), regardless of what you request. It is preferable to respect their limits in the request to be more truthful to teh users of your app. - REST variables are clamped to the button's configured variable limits before the webhook fires.
- Notifications are rate limited per recipient independently of your request budget. For example, REST pings are capped at roughly 30 per 5 minutes per recipient.