PuppyClicker
Open Webapp Download
API overview

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.

On this page
  • Concepts and terminology
  • Authentication
  • Errors
  • Rate limits
  • CORS
  • Endpoints: GET /me · GET /puppies · GET /puppies/:id/actions · POST /puppies/:id/actions · GET /clicks/received · POST /clicks/self · GET /stream
  • Behaviour and semantics
  • Managing API keys
  • Recipes

Concepts and terminology

  • Puppy: an accepted friend that the key owner controls. The v2 puppies namespace contains only your accepted friends.
  • Action: something you send a puppy. A click, a message, an osc (OpenShock zap, vibrate, or sound), a rest (the puppy's configured webhook button), or an integration (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" }
HTTPcodeMeaning
400INVALID_REQUESTBody or query failed validation. error is the first validation problem.
400TARGET_NOT_CONFIGUREDThe puppy doesn't have that hardware or webhook set up (for example an osc action to a puppy with no OpenShock device).
401MISSING_API_KEYNo key in the header or query.
401INVALID_API_KEYUnknown key.
401KEY_NOT_V2Key predates v2. Create a fresh key in Settings.
403ACCOUNT_BANNEDThe key owner's account is banned from PuppyClicker :c.
404PUPPY_NOT_FOUNDNo such puppy or you're not (yet) an accepted friend. The API never reveals which.
404ACTION_NOT_FOUNDThe rest/integration action id doesn't exist, is inactive, or isn't delegated to you.
429RATE_LIMITEDOver budget; retry after retryAfter seconds (also in the Retry-After header).
429STREAM_LIMITToo many concurrent SSE connections for your account.
500INTERNALServer error. Please report on Discord. Safe to retry with backoff.
502UPSTREAM_FAILEDThe 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:

BudgetFreeSupporter
Actions (every POST)20200
Reads (every GET except /stream)240240
Concurrent SSE stream connections (per account)15
  • 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 429 please honour the Retry-After header (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:

CapabilityTrue when
clickAlways.
messageAlways.
oscThe puppy has a validated OpenShock device configured.
restThe puppy has at least one active REST/webhook button.
integrationsThe 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. Optional intensity (0-100), duration (0-30000 ms), message (up to 150 chars).
  • message: always present. Required message (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 into params.max, plus online (whether the device is currently reachable). Commands are also clamped server side to these caps regardless of what you send. subtype selects the effect.
  • rest: one entry per active REST/webhook button. id is the config id to pass back, or the literal "legacy" for older single-endpoint setups. variables is the button's own variable schema.
  • integration: one entry per delegated integration button. Pass id back; params describes 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.

typeFields
clickintensity? 0-100, duration? 0-30000 (ms), message? up to 150 chars
messagemessage 1-150 chars (required)
oscsubtype Shock | Vibrate | Sound | Stop, intensity 0-100, duration 300-30000 (ms)
restid (config id, or "legacy"), variables? (object of number | bool | string), message? up to 150 chars
integrationid (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": "…" }
  • dndSuppressed is present on click, message, osc, rest, and integration results. It's true only when the puppy is in Do Not Disturb and shares their DND status; otherwise it's always false (their private DND state is never leaked to you). See Behaviour and semantics.
  • message echoes 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:

ParamNotes
take1-50, default 30.
cursorThe 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_…"
}
  • type is the click kind: click, zap (OpenShock), api (API / REST / integration / self), and so on.
  • source is where it originated (null for a direct friend click, or values like pound / landing for social and landing-page clicks).
  • hidden: true rows come from a user you've blocked or who blocked you. Their senderId / senderUsername / senderPoundUsername are null, but the click itself is still listed.
  • nextCursor is null when there are no more rows. To page, pass it back verbatim as cursor.

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:

  1. ready, sent once on connect:
    event: ready
    data: {"connectedAt":"2026-07-13T12:00:00.000Z"}
  2. Keepalive comments every 25 seconds (SSE comment lines, ignore them):
    : keepalive
  3. alert events, 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):

FieldNotes
idThe click or message id.
kindclick | message | comment | reaction_emoji | reaction_button.
senderUsernameDisplay name of the sender (or the key name for self and API clicks).
bodyMessage text, or *click* for a bare click.
timestampISO 8601.
durationPress-and-hold duration (ms), on click alerts that carry one.
sourcepound | landing for social and landing-page clicks; absent for friend clicks.
dndSuppressedtrue when you're in DND. The event is delivered so live views update, but clients should skip the native notification and sound.
testtrue 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)..
  • dndSuppressed privacy. On actions you send to a puppy, the flag is true only when the puppy both is in DND and shares their DND status; otherwise always false. On POST /clicks/self it reflects your own state truthfully.
  • OpenShock clamping. osc intensity 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.
© 2026 KinkyRvn · PuppyClicker Docs · Privacy · Terms · Delete account