Skip to content

Ingest API

Everything the key does, you can do yourself: a POST to /api/event with a small JSON. This is the full contract.

The endpoint

POST https://pisi.to/api/event
http

A single endpoint, public and with open CORS: any website can send it events, and the filter is the domain. It accepts text/plain, which is what sendBeacon sends and triggers no preflight, and application/json. There is no authentication: if the domain in the payload belongs to no flat, the event is dropped.

Responses

It responds before processing, so a 202 means "received", not "stored". An event for an unknown domain, one filtered as a bot or one over the limit is silently dropped with the same 202: a visitor's browser never sees an error because of us.

CodeWhen
202Whenever the body can be read. The response has no body.
400The body is empty, is not valid JSON, or is missing a required field (n, u or d).
413The body is over 8 KB. A 4,000-character URL plus a 2,000-character referrer plus props fit comfortably.
202Limit of 60 requests per second from the same IP to the same domain. Above it there is no error: it responds 202 and drops the event.

The payload

A flat JSON object. Only n, u and d are required; the rest is what the key reads from the browser, and you can leave it out if you do not have it.

FieldTypeWhat it is
nRequiredstringEvent name, up to 300 characters. pageview is a room viewed; any other name is a doorbell.
uRequiredstringFull page URL, up to 4,000 characters. The server keeps the host and the path, and drops the query string except the utm parameters.
dRequiredstringThe flat's domain, without protocol or www. Takes several separated by commas (10 at most) to send the same event to several flats. If none belongs to a flat of yours, the event is dropped.
rstringReferrer, up to 2,000 characters. Empty or missing counts as direct traffic.
w / hnumberWindow width and height in pixels. Used for the device type and the screen size breakdown.
tnumberThe device's touch points (navigator.maxTouchPoints). Tells an iPad announcing itself as a Mac apart.
lstringBrowser language (navigator.language), up to 35 characters.
pobjectEvent properties: up to 30 keys, each key up to 300 characters and each value up to 2,000. Strings, numbers and booleans only. Whatever is over gets trimmed silently; an event is never rejected because of a prop.
${ currency, amount }Event revenue: a three-letter currency (ISO 4217) and amount as a number or a string. Shown in the doorbells breakdown.
enumberSeconds with the tab visible. Only meaningful on the engagement event.
sdnumberMaximum scroll reached, 0 to 100. Only on engagement. Also accepted as s for compatibility.

Reserved names

Six names have their own meaning in the dashboard. The last four you can send from the API when the key cannot reach, for instance a form handled on the server. The first two are the base metrics: sending them by hand only makes sense with the manual variant, and if you do, you know you are counting visits.

NameWhat it means
pageviewA room viewed. Counts as a page view and opens or extends the visit.
engagementVisible time and scroll of the page being left. Carries e and sd and is added to the visit in progress; it never opens a new one.
404Page not found. Gets its own breakdown so you can see broken links.
Outbound Link: ClickClick on a link to another host. Prop url.
File DownloadFile download. Prop url.
Form: SubmitForm submission.

Examples

Both examples ring the same doorbell. Swap example.com for your domain.

With curl

The User-Agent is on purpose: the bot filter drops curl's, so without a browser one you get a 202 and the event never shows up in the dashboard.

curl -X POST https://pisi.to/api/event \
  -H "Content-Type: application/json" \
  -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" \
  -d '{"n":"Alta","u":"https://example.com/registro","d":"example.com","p":{"plan":"pro"}}'
bash

With fetch, from the browser

This is what the key does inside, minus the thousand things it handles for you: SPA routes, bfcache, deduplication, excluding the owner. If you can add the script, add the script.

fetch("https://pisi.to/api/event", {
  method: "POST",
  // text/plain evita el preflight CORS; application/json también vale.
  headers: { "Content-Type": "text/plain" },
  keepalive: true,
  body: JSON.stringify({
    n: "Alta",
    u: location.href,
    d: "example.com",
    r: document.referrer,
    w: innerWidth,
    h: innerHeight,
    t: navigator.maxTouchPoints,
    l: navigator.language,
    p: { plan: "pro" },
  }),
});
js

From the server

Same endpoint, same payload. The difference is that the IP and the User-Agent are read from the request that reaches pisi.to, and from your server that request is made by your server, not by the visitor. Three things to keep in mind:

  • Forward the visitor's User-Agent. Your server's (node-fetch, php-curl, python-requests) is filtered as a bot and the event is dropped.
  • By default the IP is your server's: every visitor comes from the same city and counts as a single one per day. For one-off events (a purchase, a signup) that is usually fine; for page views, it is not.
  • With the flat's key in X-Pisito-Key, ingest trusts the X-Forwarded-For you send and the visitor is attributed correctly. Today we configure the key ourselves: ask for it at hola@pisi.to. Once domain verification lands you will find it in the flat's settings.

Node

// Un handler cualquiera: Express, Hono, una route de Next…
export async function trackSignup(req, plan) {
  await fetch("https://pisi.to/api/event", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      // El filtro de bots mira el User-Agent: el de tu servidor no es un navegador.
      "User-Agent": req.headers["user-agent"] ?? "",
      // Con la clave del piso, la IP del visitante manda para el país.
      "X-Forwarded-For": req.ip,
      "X-Pisito-Key": process.env.PISITO_KEY,
    },
    body: JSON.stringify({
      n: "Alta",
      u: "https://example.com/registro",
      d: "example.com",
      l: req.headers["accept-language"]?.split(",")[0],
      p: { plan },
    }),
  });
}
js

PHP

<?php
$payload = json_encode([
  'n' => 'Compra',
  'u' => 'https://example.com/gracias',
  'd' => 'example.com',
  'p' => ['plan' => 'pro'],
  '$' => ['currency' => 'EUR', 'amount' => '29.90'],
]);

$ch = curl_init('https://pisi.to/api/event');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_POSTFIELDS => $payload,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_TIMEOUT => 2,
  CURLOPT_HTTPHEADER => [
    'Content-Type: application/json',
    'User-Agent: ' . ($_SERVER['HTTP_USER_AGENT'] ?? ''),
    'X-Forwarded-For: ' . ($_SERVER['REMOTE_ADDR'] ?? ''),
    'X-Pisito-Key: ' . getenv('PISITO_KEY'),
  ],
]);
curl_exec($ch);
curl_close($ch);
php

If what you want is for the key to get through blockers, you need none of this: serve the script and the endpoint from your own domain with a proxy.

See the proxy guides

What does not exist yet

So you do not go looking:

  • There is no read API for stats. It is on the way. Meanwhile, any table in the dashboard exports to CSV with the period and filters you have set, and public dashboards let you share without granting access.
  • There is no importer for history from other tools. Also on the way.

When they exist, they will show up in the changelog and here.

Stuck?

Email hola@pisi.to with your flat's URL and we will take a look.