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/eventA 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.
| Code | When |
|---|---|
202 | Whenever the body can be read. The response has no body. |
400 | The body is empty, is not valid JSON, or is missing a required field (n, u or d). |
413 | The body is over 8 KB. A 4,000-character URL plus a 2,000-character referrer plus props fit comfortably. |
202 | Limit 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.
| Field | Type | What it is |
|---|---|---|
nRequired | string | Event name, up to 300 characters. pageview is a room viewed; any other name is a doorbell. |
uRequired | string | Full page URL, up to 4,000 characters. The server keeps the host and the path, and drops the query string except the utm parameters. |
dRequired | string | The 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. |
r | string | Referrer, up to 2,000 characters. Empty or missing counts as direct traffic. |
w / h | number | Window width and height in pixels. Used for the device type and the screen size breakdown. |
t | number | The device's touch points (navigator.maxTouchPoints). Tells an iPad announcing itself as a Mac apart. |
l | string | Browser language (navigator.language), up to 35 characters. |
p | object | Event 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. |
e | number | Seconds with the tab visible. Only meaningful on the engagement event. |
sd | number | Maximum 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.
| Name | What it means |
|---|---|
pageview | A room viewed. Counts as a page view and opens or extends the visit. |
engagement | Visible 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. |
404 | Page not found. Gets its own breakdown so you can see broken links. |
Outbound Link: Click | Click on a link to another host. Prop url. |
File Download | File download. Prop url. |
Form: Submit | Form 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"}}'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" },
}),
});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 },
}),
});
}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);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.
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.
