New Pins arrive as agent-ready context — screenshot, viewport, and the exact coordinates travel with every one. See how it works
Docs

The whole integration, on one page

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.

Install

One tag per project

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.

Who is pinning

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.

Pins and notes

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.

Agent API

Three endpoints, one token

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"
EndpointWhat 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.

Closing a pin

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.

A pin

Every field, as the API returns it

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.

GET /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"
    },
    …
  ]
}
Errors

JSON, with a reason

StatusWhenBody
400since is not a timestamp, a PATCH sends neither field, or a POST has no pin object{ "error": "..." }
401Missing or invalid bearer token{ "error": "..." }
404The pin is not in this project, or the project key is unknown{ "error": "..." }
413Request body over the size cap{ "error": "..." }
422Unknown status, or a value that fails validation{ "error": "..." } or { "errors": [...] }
429Over the rate limitPlain text, not JSON; the window is one minute
Ingest

What the widget posts

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.

Limits

What the server enforces

Pin intake60 pin posts a minute per IP address
Agent API120 agent requests a minute per IP address
Request body3 MB
Screenshot2 MB; the widget captures 500×250 PNG for pins. Over the cap, a pin is saved without it and a note is rejected
ListingUp 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.

The agent brief

Paste this into whatever you run

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.
Your data

It leaves whenever you do

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.