One script tag collects the pins. One bearer token reads and closes them. This page covers both, every field a pin carries, and the limits the server enforces.
Create a project in the dashboard. Its page shows this snippet with your key
filled in. Paste it before </body> on every page you want
feedback on.
<script src="https://pinpoint-router-872952356c0f.herokuapp.com/embed/SITE_KEY.js" async></script>
The loader adds the widget's stylesheet and script from Pinpoint's own origin, mounts the trigger button in the corner you chose for the project, and posts every saved pin to your inbox. No pin data is ever sent back to the page.
Before the first pin, the widget asks for a name and an optional email, and remembers both in the browser. If your page already knows the user, put them on the tag and nobody is asked:
<script src="https://pinpoint-router-872952356c0f.herokuapp.com/embed/SITE_KEY.js"
data-name="Jane Doe" data-email="jane@example.com" async></script>
A page that only learns who the user is after it loads can set
window.pinpointUser = { name, email } any time before a pin is
dropped. The host's answer always outranks a typed name.
A pin is anchored to a point. It carries the click's coordinates,
both in pixels and as a 0 to 1 fraction of the document's width and height, and
a 500×250 screenshot of the area around it. A note is about the whole page: the
reviewer pastes or drops in a screenshot, and it carries no coordinates. Both
land in the same inbox and come back from the same API. Read kind
before trusting x and y.
Every project has its own bearer token, shown under Agent access on the project page, where a project or workspace owner can rotate it. Send it on every request.
curl -s -H "Authorization: Bearer $PINPOINT_API_TOKEN" \
"https://pinpoint-router-872952356c0f.herokuapp.com/api/v1/pins?status=pending"
| Endpoint | What it does |
|---|---|
| GET /api/v1/project | The project's name, slug, pins_count, and counts_by_status. |
| GET /api/v1/pins |
Pins, newest first. Filters: status=pending|completed,
kind=pin|note, page_url, since (ISO 8601,
inclusive, against created_at), and limit (1 to 200,
default 50). Returns project, count (every match), and
pins (up to the limit).
|
| PATCH /api/v1/pins/:id |
Sets status (pending or completed) and/or
external_url. Returns the updated pin. Attach the PR or commit
URL when you close one.
|
curl -s -X PATCH -H "Authorization: Bearer $PINPOINT_API_TOKEN" \
"https://pinpoint-router-872952356c0f.herokuapp.com/api/v1/pins/PIN_ID" \
-d status=completed -d external_url=https://github.com/you/repo/pull/12
Completed pins keep their screenshot and coordinates and leave the pending
view. Patch status=pending to reopen one.
x and y are page pixels at the time of the click;
x_percent and y_percent are the same spot as a 0 to 1
fraction of the whole document, so they survive any screen size. viewport and document say how big the page
was. thumbnail_url redirects to the stored screenshot.
/api/v1/pins?status=pending
{
"project": "yourdemo.site",
"count": 3,
"pins": [
{
"id": 47,
"client_pin_id": "pin-mfk3x9qz-8c1f2d",
"kind": "pin",
"status": "pending",
"body": "Misaligned button on mobile",
"author": { "name": "Jane Doe", "email": "jane@example.com" },
"page_url": "https://yourdemo.site/landing",
"page_title": "Landing",
"x": 304,
"y": 1188,
"x_percent": 0.77949,
"y_percent": 0.41538,
"viewport": { "width": 390, "height": 844 },
"document": { "width": 390, "height": 2860 },
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) … Safari/604.1",
"dropped_at": "2026-09-07T18:02:11Z",
"created_at": "2026-09-07T18:02:12Z",
"completed_at": null,
"external_url": null,
"thumbnail_url": "https://pinpoint-router-872952356c0f.herokuapp.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMi…/pin-mfk3x9qz-8c1f2d.png"
},
…
]
}
| Status | When | Body |
|---|---|---|
| 400 | since is not a timestamp, a PATCH sends neither field, or a POST has no pin object | { "error": "..." } |
| 401 | Missing or invalid bearer token | { "error": "..." } |
| 404 | The pin is not in this project, or the project key is unknown | { "error": "..." } |
| 413 | Request body over the size cap | { "error": "..." } |
| 422 | Unknown status, or a value that fails validation | { "error": "..." } or { "errors": [...] } |
| 429 | Over the rate limit | Plain text, not JSON; the window is one minute |
The widget sends every saved pin to POST /api/v1/pins with the
project key and no token, which is why a key can only ever file feedback. Your
own tooling can post the same shape.
{ "project_key": "SITE_KEY",
"pin": { "id": "pin-mfk3x9qz-8c1f2d", "kind": "pin", "body": "Misaligned button on mobile",
"pageUrl": "https://yourdemo.site/landing", "pageTitle": "Landing",
"x": 304, "y": 1188, "xPercent": 0.77949, "yPercent": 0.41538,
"viewport": { "width": 390, "height": 844 }, "document": { "width": 390, "height": 2860 },
"author": { "name": "Jane Doe", "email": "jane@example.com" },
"thumbnail": "data:image/png;base64,...", "createdAt": "2026-09-07T18:02:11Z" } }
id is the client's own ID for the pin. Posting the same ID twice
answers 201 with the same row both times, which is how the loader retries safely. A note needs a
thumbnail and no coordinates. Thumbnails are PNG, JPEG, or WebP data
URIs.
| Pin intake | 60 pin posts a minute per IP address |
|---|---|
| Agent API | 120 agent requests a minute per IP address |
| Request body | 3 MB |
| Screenshot | 2 MB; the widget captures 500×250 PNG for pins. Over the cap, a pin is saved without it and a note is rejected |
| Listing | Up to 200 pins per request, newest first; poll for new ones with since |
The intake limit exists to make a leaked key boring, not to meter you. It sits well above what a reviewer can type.
Every project prints this brief with its own token filled in. It is self-contained: endpoints, fields, and the rule about not closing a pin before the change is in. Claude Code, Codex, Cursor, or a shell script all work.
I collect visual feedback with Pinpoint. Pins are dropped right on
the rendered page, so each one carries the URL, the spot that was clicked,
and a screenshot. Pull the pending pins for "your project" and work through them.
Base URL: https://pinpoint-router-872952356c0f.herokuapp.com
Auth: Authorization: Bearer $PINPOINT_API_TOKEN
Endpoints (every one needs that Authorization header)
GET /api/v1/project name, slug, and pin counts by status
GET /api/v1/pins newest first. Filters: status=pending|completed,
kind=pin|note, page_url, since (ISO8601),
limit (1-200, def 50)
PATCH /api/v1/pins/:id status and/or external_url
Each pin returns: body (the request itself), kind (pin, or note for a
whole-page screenshot with no x/y), author (name and email of whoever
dropped it, when known), page_url, page_title, x/y in page pixels and
x_percent/y_percent as 0-1 fractions of the document, viewport and
document dimensions, thumbnail_url (a screenshot of the page as it looked),
status, created_at, external_url.
Start here:
curl -s -H "Authorization: Bearer $PINPOINT_API_TOKEN" \
"https://pinpoint-router-872952356c0f.herokuapp.com/api/v1/pins?status=pending"
For each pin: find the code that renders page_url and make the change the
body asks for, using x_percent/y_percent and thumbnail_url to settle any
ambiguity about which element is meant. Once the change is committed,
close the pin out:
curl -s -X PATCH -H "Authorization: Bearer $PINPOINT_API_TOKEN" \
"https://pinpoint-router-872952356c0f.herokuapp.com/api/v1/pins/PIN_ID" \
-d status=completed -d external_url=PR_OR_COMMIT_URL
Do not mark a pin completed before its change is actually in. Tell me about
any pin you could not act on and why, rather than guessing at it.
Every project and every workspace exports its pins as CSV from the dashboard, one row per pin with the same fields as the API, and the API reads everything out with your own token. The widget underneath, pinpoint.js, is MIT: the collection half of this is a file you already have.