# What PULSE offers (/api)
PULSE publishes **two separate interfaces**, and they are not the same size.
} title="The HTTP API" description="Twelve endpoints. One header. Callable from a browser, a server, a cron job or curl. This is the product." />
} title="The browser SDK" description="A convenience layer over six of them, which also manages the session id, buffers exposures and captures device and referrer for you." />
`@thepulseapi/sdk` has a method for six of the twelve endpoints. Everything else, including
the Decision Audit, is HTTP only. If you are evaluating what PULSE can do, read this page
rather than the [SDK reference](/sdk).
## The product surface [#the-product-surface]
Every row below is reachable with a publishable key and the `x-api-key` header. Nothing here
needs a login.
| Endpoint | What it is for | Counts toward usage | SDK method |
| --- | --- | --- | --- |
| `POST /track` | Signals in, personas out | Yes | `track()` |
| `POST /track/behavior` | One named behaviour | Yes | none |
| `POST /track/keywords` | A raw search query, extracted for you | Yes | none |
| `POST /session` | The personas inferred so far | Yes | `getSession()` |
| `POST /catalog/ranked` | Rank the catalogue PULSE holds | Yes | `rankedCatalog()` |
| `POST /search` | Rank products you pass in | Yes | `search()` |
| `POST /rerank` | Reorder a result set from your own engine | Yes | none |
| `POST /suggest` | Query suggestions, scored on the session | **No** | none |
| `POST /catalog/audit` | What a catalogue cannot answer | Yes | none |
| `POST /catalog/verify` | What a repair actually changed | Yes | none |
| `POST /event` | A named outcome | **No** | `event()` |
| `POST /exposure` | What was actually rendered | **No** | `exposure()` |
### Why three of them are free [#why-three-of-them-are-free]
`/event` and `/exposure` are how you prove the ranking worked. Charging for measurement makes
switching it off the rational move, which blinds the learning loop and destroys the value case
in the same stroke.
`/suggest` fires on every keystroke. Metering it like `/search` would bill per character typed,
so someone spelling out a query would pay ten times for one search.
`/track` and `/track/behavior` are both billed as `track`, and `/catalog/ranked`,
`/catalog/audit` and `/catalog/verify` are all billed as `catalog`. Your invoice shows a line
per operation rather than one total, because `/search` costs real compute and `/session`
costs almost nothing.
## The four products, and the calls behind them [#the-four-products-and-the-calls-behind-them]
} title="Decision Audit" href="/api/v1/audit" description="POST /catalog/audit. Which missing field to record first, ranked, in your own catalogue." />
} title="Decision Verification" href="/api/v1/audit" description="POST /catalog/verify with repairs. Recompute after the work and see what moved." />
} title="Decision Monitor" href="/api/v1/audit" description="The same call without repairs. Catalogues decay; this is what notices." />
} title="Decision API" href="/api/v1" description="track, session, ranked, search, rerank, suggest. The live runtime." />
Audit, Verification and Monitor are **one computation over a diff**. There is no third endpoint
for Monitor because there is no third thing to compute: the only difference is whether anything
was claimed about the change.
## What is not on this page [#what-is-not-on-this-page]
There is a second, much larger API behind a **JWT**, not an API key: organizations, users,
roles, API keys, catalogue management, billing and subscriptions. That is the management plane
the [merchant portal](https://app.thepulseapi.com) is built on. It is not metered, it is not
sold as a product, and it is not documented here yet.
Routes exist that are not on this page. Some are internal, some are reachable only with a
login, and some resolve at the gateway and are not served downstream. If it is not written
down here, do not build on it.
# Audit a catalogue (/api/v1/audit)
The ranking endpoints answer "which products first". These two answer a different question:
**can this catalogue answer the questions shoppers are asking of it at all.**
Both take a CSV as a string in the body rather than a file upload. An audit is a pure function
over text, so multipart would add a parser, a temporary file and a cleanup path for no gain at a
size where a category export is tens of kilobytes.
Neither call stores anything unless you ask it to.
## `POST /catalog/audit` [#post-catalogaudit]
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/catalog/audit" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"csv": "id,category,size,material\n1,helmet,L,abs\n2,helmet,,\n",
"source": "merchant",
"amortisationHorizon": 5000
}'
```
Without it the response carries no `action`. Comparing "ask the shopper" against "record the
fact once" needs to know how many shoppers the fact would serve, and picking a number on your
behalf would bury the assumption the whole comparison rests on.
## `POST /catalog/verify` [#post-catalogverify]
The same request does two jobs. Send `before` and `after` on their own and it is a monitor: what
changed between two exports, with nothing claimed. Add `repairs` and the same diff grades the
claims.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/catalog/verify" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"before": "id,category,size\n1,helmet,\n",
"after": "id,category,size\n1,helmet,L\n",
"repairs": [{ "attribute": "size", "method": "manual", "actualHours": 3 }],
"category": "Helmets"
}'
```
Monitor and verification are the same computation over the same diff. The only difference is
whether anything was claimed about it.
# Errors (/api/v1/errors)
Every error has the same envelope.
```json title="401 Unauthorized"
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or revoked API key"
},
"timestamp": "2026-10-19T08:27:01.722Z",
"path": "/api/v1/session",
"correlationId": "a4cc87c6bf160f30"
}
```
Quote it when you report a problem. It is how a single request is found in the logs, and it
turns "ranking looked wrong yesterday" into one lookup.
## Codes [#codes]
| Code | HTTP | What it usually means |
| --- | --- | --- |
| `BAD_REQUEST` | 400 | Malformed body, or an empty body with a JSON content type |
| `UNAUTHORIZED` | 401 | Missing, wrong, or revoked key. Check the header is `x-api-key` |
| `FORBIDDEN` | 403 | The key is valid but not for this organization's data |
| `NOT_FOUND` | 404 | No such route. Check the `/api/v1` prefix |
| `TOO_MANY_REQUESTS` | 429 | Rate limited at the gateway |
| `SERVICE_UNAVAILABLE` | 503 | A downstream service is failing fast behind its circuit breaker |
## Two that are worth knowing apart [#two-that-are-worth-knowing-apart]
A revoked or expired key returns 401 exactly like a typo. Keys can be given an expiry at
creation. Check the dashboard before debugging your code.
The base URL includes the version: `https://api.thepulseapi.com/api/v1`. A request to
`thepulseapi.com/v1/...` reaches the marketing site, not the API, and will not look like an
API error at all.
# Record exposure (/api/v1/exposure)
Ranking cannot be graded without knowing what was actually shown. A click rate over results
nobody saw is not a measurement.
## `POST /exposure` [#post-exposure]
```ts
pulse.exposure(decisionId, items); // buffered
await pulse.flushExposures(); // send now
```
`exposure` buffers, because a product grid renders many items at once and one request per card
would be absurd. `flushExposures` sends what is pending, and the SDK also flushes on page hide.
If you asked for 24 and showed 12 above the fold, the honest exposure is what the shopper could
actually see. Over-reporting exposure makes your own ranking look worse than it is.
# Authentication (/api/v1)
## Base URL [#base-url]
```bash
https://api.thepulseapi.com/api/v1
```
## The header [#the-header]
Authenticate with **`x-api-key`**. Not `Authorization: Bearer`.
```bash title="A real request"
curl -X POST "https://api.thepulseapi.com/api/v1/session" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{}'
```
## Getting a key [#getting-a-key]
Sign in to the [dashboard](https://app.thepulseapi.com), open **Integration → API keys**, and
create one.
| | |
| --- | --- |
| **Format** | Always `pk_live_` followed by base64url randomness |
| **Publishable** | Designed to be visible in browser code. It can send signals and read rankings, nothing else |
| **Scoped** | Belongs to one organization and cannot read another's data |
| **Shown once** | The full value is returned only at creation. Only the hash is stored |
| **Revocable** | Revocation is immediate |
Every issued key is `pk_live_`. There is no `pk_test_` and no sandbox environment yet. To keep
experiments out of your reporting, create a **separate key** for development and revoke it when
you are done.
## What a publishable key cannot do [#what-a-publishable-key-cannot-do]
Catalogue writes (`/products`, `/categories`) authenticate as a signed-in dashboard session, not
with a publishable key. They are not part of this reference.
# Rank products (/api/v1/ranking)
## `POST /catalog/ranked` [#post-catalogranked]
Ranks the catalogue already synced to PULSE.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/catalog/ranked" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"limit": 24
}'
```
```ts
const { items } = await pulse.rankedCatalog({ limit: 24 });
```
## `POST /search` [#post-search]
Ranks products **you pass in**, against the session.
```ts
const { results } = await pulse.search(products, { limit: 24 });
results[0].scoreBreakdown; // the signals that matched, and their weights
```
## `POST /rerank` [#post-rerank]
Reorders an existing result set you already produced elsewhere, which is the lightest way to put
PULSE in front of a search engine you are keeping.
## `POST /suggest` [#post-suggest]
Query suggestions scored against the current session.
`search` and `rankedCatalog` are not interchangeable. One ranks what you hand it, the other
ranks what PULSE holds. Sending a catalogue you already synced to `search` is a wasted payload.
# Read the session (/api/v1/session)
## `POST /session` [#post-session]
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/session" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{ "sessionId": "sess_abc123" }'
```
```ts
const session = await pulse.getSession();
```
Returns whether the session was found and the personas accumulated against it. A session id that
has never been written returns `found: false`, which is the quickest way to confirm your signals
are landing.
If `found` is false after a `track` call, the signals are not arriving. That is one request to
check, rather than reasoning backwards from a ranking that looks slightly wrong.
# Send signals (/api/v1/track)
## `POST /track` [#post-track]
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/track" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"signals": [
{ "type": "category_view", "value": "helmets-safety" },
{ "type": "price_filter", "value": "500000-1500000" }
]
}'
```
With the SDK:
```ts
await pulse.track([
{ type: 'category_view', value: 'helmets-safety' },
{ type: 'price_filter', value: '500000-1500000' },
]);
```
Signals are additive. Send them as they happen rather than batching a whole session at the end:
persona scores decay, so ordering carries meaning.
Every endpoint below reads it from the body, not from the API key. Omitting it returns `400`
with a validation error naming the field. The SDK sends it for you from `init()`.
`/track` also captures device, referrer and time from the request itself, so a call with no
`signals` at all is still worth making: it opens the session with what the request already says.
## `POST /track/behavior` [#post-trackbehavior]
One behaviour, named, when you have nothing to say about a category or a price.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/track/behavior" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"behavior": "sort_price_asc",
"metadata": { "page": "category" }
}'
```
`behavior` is your own slug, capped at 128 characters. Sorting by price ascending says something
about a shopper that no category view does.
## `POST /track/keywords` [#post-trackkeywords]
A search query, turned into keyword signals for you.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/track/keywords" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"query": "mu bao hiem fullface"
}'
```
Send the raw query. Extraction happens server-side, and the query is capped at 512 characters
because the work is linear in its length.
## `POST /event` [#post-event]
A named event, for things that are not persona signals.
```ts
await pulse.event('checkout_started', { cartValue: 1250000 });
```
# Versioning (/api/versioning)
## The API is versioned. The guides are not. [#the-api-is-versioned-the-guides-are-not]
| | Versioned | Why |
| --- | --- | --- |
| **API reference** | **Yes**, `/api/v1` | It is a contract. A request you wrote last year has to keep working, so the page describing it has to keep existing |
| Guides, concepts, frameworks | No | They describe behaviour, not a contract. "How inference works" has no v1 and v2 |
| SDK reference | No | The SDK follows the API. Where a method needs a minimum version, the page says so inline |
The version in the docs URL matches the version in the request URL:
```bash
https://api.thepulseapi.com/api/v1/track → /api/v1/track
```
There has never been another. The path carries the version anyway, because a URL is cheap to
choose now and expensive to change once people have linked to it.
## What counts as a breaking change [#what-counts-as-a-breaking-change]
A new major version happens only when an existing, correct request would start behaving
differently.
- Removing an endpoint, a field, or an accepted value
- Renaming anything in a request or response
- Making an optional request field required
- Narrowing a type, for example a string that becomes an enum
- Changing the meaning of a field while keeping its name. **This is the worst one**, because nothing fails loudly
- Adding a new endpoint
- Adding an optional request field
- Adding a field to a response. **Parse responses permissively**: unknown fields will appear
- Adding a new value to an output enum, unless the docs say the set is closed
- Making a validation message clearer
## What we owe you when a version changes [#what-we-owe-you-when-a-version-changes]
1. A new path, `/api/v1/v2`, with **v1 still published and still working**
2. A migration page naming every behavioural difference, not a summary
3. A deprecation notice on each v1 page pointing at its v2 equivalent
4. A stated end date for v1, not "eventually"
## The SDK versions separately [#the-sdk-versions-separately]
[`@thepulseapi/sdk`](https://www.npmjs.com/package/@thepulseapi/sdk) follows
[semver](https://semver.org) and tracks the API rather than mirroring it.
Pre-1.0, a **minor** version may contain a breaking change. That is what 0.x means in semver,
and it is worth pinning an exact version until 1.0.
The CDN build is versioned the same way, with an immutable path per release and a floating major:
```
https://cdn.thepulseapi.com/sdk/v0.1.5/pulse.iife.js exact, never changes
https://cdn.thepulseapi.com/sdk/v0/pulse.iife.js patches arrive here
```
# C# and .NET (/backend/csharp)
No package to install. `HttpClient` and `System.Text.Json` are in the framework.
## A typed client [#a-typed-client]
```csharp title="PulseClient.cs"
using System.Net.Http.Json;
public sealed record Signal(string Type, string Value);
public sealed class PulseClient(HttpClient http, string organizationId)
{
public Task TrackAsync(string sessionId, IEnumerable signals) =>
PostAsync("track", new
{
sessionId,
organizationId,
signals = signals.Select(s => new { type = s.Type, value = s.Value }),
});
public Task RankedCatalogAsync(string sessionId, int limit = 24) =>
PostAsync("catalog/ranked", new { sessionId, organizationId, limit });
public Task SessionAsync(string sessionId) =>
PostAsync("session", new { sessionId });
private async Task PostAsync(string path, object body)
{
var response = await http.PostAsJsonAsync(path, body);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync();
}
}
```
## Registration [#registration]
```csharp title="Program.cs"
builder.Services.AddHttpClient(client =>
{
// The trailing slash matters. Without it, a relative path replaces the last segment
// and every request goes to https://api.thepulseapi.com/api/track instead.
client.BaseAddress = new Uri("https://api.thepulseapi.com/api/v1/");
client.DefaultRequestHeaders.Add("x-api-key", builder.Configuration["Pulse:ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(5);
});
```
`AddHttpClient` is what you want rather than `new HttpClient()`: it pools handlers and rotates
them, which is the difference between reusing connections and exhausting sockets under load.
```csharp
public class CatalogController(PulseClient pulse) : Controller
{
public async Task Category(string slug)
{
var sessionId = Request.Cookies["pulse_session"];
if (sessionId is null) return View(DefaultOrder(slug));
try
{
var ranked = await pulse.RankedCatalogAsync(sessionId);
return View(ranked);
}
catch (HttpRequestException)
{
// Ranking is an enhancement, never a reason to fail the page.
return View(DefaultOrder(slug));
}
}
}
```
`System.Text.Json` serialises anonymous object members verbatim, so `sessionId` stays
`sessionId`. If you swap in a named class with PascalCase properties, set
`JsonNamingPolicy.CamelCase` or the API will reject the body as missing its required fields.
# Go (/backend/go)
```go title="pulse/pulse.go"
package pulse
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
const base = "https://api.thepulseapi.com/api/v1"
type Signal struct {
Type string `json:"type"`
Value string `json:"value"`
}
type Client struct {
apiKey string
orgID string
http *http.Client
}
func New(apiKey, orgID string) *Client {
return &Client{
apiKey: apiKey,
orgID: orgID,
// http.DefaultClient has no timeout at all, which is a goroutine leak waiting
// for a slow upstream. Always supply one.
http: &http.Client{Timeout: 5 * time.Second},
}
}
func (c *Client) post(ctx context.Context, path string, body map[string]any, out any) error {
// organizationId is read from the body rather than from the key, and is required
// on nearly every endpoint.
body["organizationId"] = c.orgID
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("pulse %s: %s", path, res.Status)
}
return json.NewDecoder(res.Body).Decode(out)
}
func (c *Client) Track(ctx context.Context, sessionID string, signals []Signal) (map[string]any, error) {
var out map[string]any
err := c.post(ctx, "/track", map[string]any{"sessionId": sessionID, "signals": signals}, &out)
return out, err
}
func (c *Client) RankedCatalog(ctx context.Context, sessionID string, limit int) (map[string]any, error) {
var out map[string]any
err := c.post(ctx, "/catalog/ranked", map[string]any{"sessionId": sessionID, "limit": limit}, &out)
return out, err
}
```
```go
p := pulse.New(os.Getenv("PULSE_API_KEY"), os.Getenv("PULSE_ORG_ID"))
ranked, err := p.RankedCatalog(r.Context(), sessionID, 24)
if err != nil {
// Serve the catalogue in its default order. Personalisation is an enhancement.
ranked = nil
}
```
Threading `r.Context()` through means a shopper who navigates away cancels the ranking call
instead of leaving it running against a response nobody will read.
# Overview (/backend)
There is no PULSE library for your backend language, and you do not need one. Every endpoint is
`POST`, every body is JSON, and authentication is one header.
```
POST https://api.thepulseapi.com/api/v1/
Content-Type: application/json
x-api-key: pk_live_...
```
That is the whole integration surface. The guides in this section are the same three calls
written idiomatically per language, so you can copy one and delete the rest.
## Which calls belong on a server [#which-calls-belong-on-a-server]
Most of PULSE is designed for the browser, where the SDK already handles sessions, batching and
exposure buffering. Reach for the HTTP API directly when the work is genuinely server-side:
} title="Server-rendered pages" description="Ranking a grid during SSR, where there is no browser yet to hold a session." />
} title="Catalogue audits" description="A scheduled job grading an export. Nothing about it belongs in a page." />
} title="Backfills and pipelines" description="Replaying events you already collected, or recording outcomes your payment webhook knows about and the browser does not." />
## One thing to get right first [#one-thing-to-get-right-first]
`sessionId` and `organizationId` are required on nearly every call, and both come from the body
rather than from the API key. A request without them returns `400` with the field named. See
[Send signals](/api/v1/track).
A session is yours to name. It is a string you generate, up to 128 characters, and the only rule
is that the same shopper keeps the same one. If the browser is already running the SDK, read it
with `pulse.getSessionId()` and pass it to your server rather than inventing a second session for
the same person.
Keys are `pk_live_`, publishable, and the same key works from a browser and from a server.
There is no separate secret key today, which means a key leaked from your server is no worse
and no better than one read out of your page source. Scope and rotate accordingly.
## The languages [#the-languages]
} title="Python" href="/backend/python" description="requests or httpx, with Django and FastAPI notes." />
} title="C# and .NET" href="/backend/csharp" description="HttpClient, typed, with the DI registration." />
} title="Go" href="/backend/go" description="net/http and encoding/json. No dependencies." />
} title="Rust" href="/backend/rust" description="reqwest and serde." />
} title="Java" href="/backend/java" description="java.net.http, built in since 11." />
} title="Ruby" href="/backend/ruby" description="net/http from the standard library." />
} title="PHP" href="/backend/php" description="cURL, and Guzzle for Laravel." />
} title="Node.js" href="/backend/nodejs" description="The SDK runs server-side too." />
# Java (/backend/java)
```java title="PulseClient.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
public final class PulseClient {
private static final String BASE = "https://api.thepulseapi.com/api/v1";
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final ObjectMapper json = new ObjectMapper();
private final String apiKey;
private final String organizationId;
public PulseClient(String apiKey, String organizationId) {
this.apiKey = apiKey;
this.organizationId = organizationId;
}
public JsonNode track(String sessionId, Object signals) throws Exception {
return post("/track", Map.of(
"sessionId", sessionId,
"organizationId", organizationId,
"signals", signals));
}
public JsonNode rankedCatalog(String sessionId, int limit) throws Exception {
return post("/catalog/ranked", Map.of(
"sessionId", sessionId,
"organizationId", organizationId,
"limit", limit));
}
private JsonNode post(String path, Map body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("x-api-key", apiKey)
// connectTimeout above only covers establishing the connection. This is the
// one that bounds a server which accepted the socket and then went quiet.
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
.build();
HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new IllegalStateException("PULSE " + path + ": " + response.statusCode());
}
return json.readTree(response.body());
}
}
```
`HttpClient` is thread safe and pools connections, so build one and keep it. In Spring, that
means a `@Bean`, not a `new` inside a controller.
```java
@Bean
PulseClient pulseClient(
@Value("${pulse.api-key}") String apiKey,
@Value("${pulse.organization-id}") String organizationId) {
return new PulseClient(apiKey, organizationId);
}
```
`connectTimeout` on the client and `timeout` on the request do different jobs. Set only the
first and a server that accepts your connection and then stalls will hold the thread
indefinitely.
# Node.js (/backend/nodejs)
`@thepulseapi/sdk` is isomorphic. The same package works in Node, so a server-rendered page can
use the client it already knows.
```ts title="pulse.server.ts"
import { PulseClient } from '@thepulseapi/sdk';
// One client per process. It holds no per-shopper state: the session id is passed per call.
export const pulse = new PulseClient();
pulse.init({
apiKey: process.env.PULSE_API_KEY!,
organizationId: process.env.PULSE_ORG_ID!,
autoTrack: false, // there is no browser here to capture a device or a referrer from
});
```
It exists to capture device, referrer and time from the browser. On a server those describe
your datacentre, and sending them pollutes the session with signals about a machine nobody
shopped from.
## Without the package [#without-the-package]
Node 18 and later ship `fetch`, so the whole integration is one function.
```ts title="pulse.ts"
const BASE = 'https://api.thepulseapi.com/api/v1';
async function post(path: string, body: Record): Promise {
const response = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.PULSE_API_KEY!,
},
body: JSON.stringify({ organizationId: process.env.PULSE_ORG_ID, ...body }),
// fetch has no default timeout, and a hung ranking call should not hold a request.
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`PULSE ${path}: ${response.status}`);
return response.json() as Promise;
}
export const track = (sessionId: string, signals: unknown[]) =>
post('/track', { sessionId, signals });
export const rankedCatalog = (sessionId: string, limit = 24) =>
post('/catalog/ranked', { sessionId, limit });
```
# PHP (/backend/php)
For the browser half of a PHP app, see the [Laravel guide](/frameworks/laravel). This page is the
server half: calls your PHP makes itself.
## Plain PHP [#plain-php]
```php title="Pulse.php"
post('/track', ['sessionId' => $sessionId, 'signals' => $signals]);
}
public function rankedCatalog(string $sessionId, int $limit = 24): array
{
return $this->post('/catalog/ranked', ['sessionId' => $sessionId, 'limit' => $limit]);
}
private function post(string $path, array $body): array
{
// organizationId is read from the body rather than the key, and is required.
$body['organizationId'] = $this->organizationId;
$ch = curl_init(self::BASE . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $this->apiKey,
],
// JSON_THROW_ON_ERROR, so a body that failed to encode is an exception
// rather than the string "false" posted to the API.
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $status >= 300) {
throw new RuntimeException("PULSE {$path}: {$status}");
}
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}
```
## Laravel [#laravel]
Guzzle is already there, wrapped as `Http`.
```php title="app/Services/Pulse.php"
config('services.pulse.key')])
->timeout(5)
->post('https://api.thepulseapi.com/api/v1/catalog/ranked', [
'sessionId' => $sessionId,
'organizationId' => config('services.pulse.org'),
'limit' => $limit,
])
->throw()
->json();
}
}
```
```php title="config/services.php"
'pulse' => [
'key' => env('PULSE_API_KEY'),
'org' => env('PULSE_ORG_ID'),
],
```
A ranking call inside a Blade template runs on every render with no timeout you control and no
way to fall back. Resolve it in the controller, pass the result in, and render catalogue order
when it fails.
# Python (/backend/python)
Or `requests`, if it is already in the project. Both work; `httpx` is here because it gives you
the async client FastAPI wants without a second dependency.
## A client [#a-client]
```python title="pulse.py"
import os
import httpx
BASE = "https://api.thepulseapi.com/api/v1"
class Pulse:
"""Thin wrapper over the HTTP API. Every call is POST and returns JSON."""
def __init__(self, api_key: str, organization_id: str, timeout: float = 5.0):
self._org = organization_id
self._client = httpx.Client(
base_url=BASE,
timeout=timeout,
headers={"x-api-key": api_key, "Content-Type": "application/json"},
)
def _post(self, path: str, body: dict) -> dict:
# organizationId is required on nearly every endpoint and is read from the body,
# not from the key, so it is filled in here rather than at every call site.
response = self._client.post(path, json={"organizationId": self._org, **body})
response.raise_for_status()
return response.json()
def track(self, session_id: str, signals: list[dict]) -> dict:
return self._post("/track", {"sessionId": session_id, "signals": signals})
def session(self, session_id: str) -> dict:
# /session takes only sessionId. Sending organizationId is harmless.
return self._post("/session", {"sessionId": session_id})
def ranked_catalog(self, session_id: str, limit: int = 24, **kwargs) -> dict:
return self._post(
"/catalog/ranked", {"sessionId": session_id, "limit": limit, **kwargs}
)
pulse = Pulse(os.environ["PULSE_API_KEY"], os.environ["PULSE_ORG_ID"])
```
```python
pulse.track("sess_abc123", [{"type": "category_view", "value": "helmets-safety"}])
ranked = pulse.ranked_catalog("sess_abc123", limit=24)
```
## Django [#django]
Build the client once at module level, not per request. `httpx.Client` keeps a connection pool
and creating one per view throws that away.
```python title="catalog/views.py"
from django.shortcuts import render
from .pulse import pulse
def category(request, slug):
# The browser owns the session. Read it rather than minting a second one for the
# same shopper: two session ids means the inference starts from nothing twice.
session_id = request.COOKIES.get("pulse_session")
products = []
if session_id:
try:
products = pulse.ranked_catalog(session_id, limit=24, categorySlug=slug)["items"]
except Exception:
# Ranking is an enhancement. A page that fails to render because personalisation
# was slow is worse than a page in catalogue order.
products = []
return render(request, "category.html", {"products": products or default_order(slug)})
```
## FastAPI [#fastapi]
Use the async client, and hold it on the app so it is created once and closed on shutdown.
```python title="main.py"
import os
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
BASE = "https://api.thepulseapi.com/api/v1"
ORG = os.environ["PULSE_ORG_ID"]
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pulse = httpx.AsyncClient(
base_url=BASE,
timeout=5.0,
headers={"x-api-key": os.environ["PULSE_API_KEY"]},
)
yield
await app.state.pulse.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/ranked/{session_id}")
async def ranked(session_id: str, limit: int = 24):
response = await app.state.pulse.post(
"/catalog/ranked",
json={"sessionId": session_id, "organizationId": ORG, "limit": limit},
)
response.raise_for_status()
return response.json()
```
Neither `requests` nor `httpx` has a default timeout in the shape most people expect. A
ranking call that hangs will hold a worker until something else gives up. Five seconds is
generous; the call is usually tens of milliseconds.
# Ruby (/backend/ruby)
```ruby title="lib/pulse.rb"
require "json"
require "net/http"
require "uri"
class Pulse
BASE = URI("https://api.thepulseapi.com/api/v1").freeze
def initialize(api_key:, organization_id:)
@api_key = api_key
@organization_id = organization_id
end
def track(session_id, signals)
post("/track", sessionId: session_id, signals: signals)
end
def ranked_catalog(session_id, limit: 24)
post("/catalog/ranked", sessionId: session_id, limit: limit)
end
def session(session_id)
post("/session", sessionId: session_id)
end
private
def post(path, **body)
# organizationId is required on nearly every endpoint and is read from the body,
# not from the key.
body[:organizationId] = @organization_id
request = Net::HTTP::Post.new(BASE.path + path)
request["Content-Type"] = "application/json"
request["x-api-key"] = @api_key
request.body = JSON.generate(body)
response = Net::HTTP.start(
BASE.host, BASE.port,
use_ssl: true,
open_timeout: 5,
read_timeout: 5,
) { |http| http.request(request) }
raise "PULSE #{path}: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
end
```
## Rails [#rails]
```ruby title="config/initializers/pulse.rb"
PULSE = Pulse.new(
api_key: ENV.fetch("PULSE_API_KEY"),
organization_id: ENV.fetch("PULSE_ORG_ID"),
)
```
```ruby title="app/controllers/catalog_controller.rb"
def show
session_id = cookies[:pulse_session]
@products = if session_id
PULSE.ranked_catalog(session_id, limit: 24)["items"]
end
rescue StandardError
# Personalisation is an enhancement. Catalogue order is a working page.
@products = nil
ensure
@products ||= Product.where(category: params[:slug]).limit(24)
end
```
`open_timeout` bounds the connection and `read_timeout` bounds the wait for a response. Set
only the first and a server that accepts your connection and then stalls holds the request
until something upstream gives up.
# Rust (/backend/rust)
```toml title="Cargo.toml"
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
```rust title="src/pulse.rs"
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
const BASE: &str = "https://api.thepulseapi.com/api/v1";
#[derive(Serialize)]
pub struct Signal {
#[serde(rename = "type")]
pub kind: String,
pub value: String,
}
#[derive(Clone)]
pub struct Pulse {
// reqwest::Client is already an Arc around a connection pool, so cloning it is cheap
// and sharing one across tasks is the intended use. Building one per request is not.
http: reqwest::Client,
org_id: String,
}
impl Pulse {
pub fn new(api_key: &str, org_id: impl Into) -> reqwest::Result {
let mut headers = reqwest::header::HeaderMap::new();
let mut key = reqwest::header::HeaderValue::from_str(api_key).expect("valid api key");
key.set_sensitive(true);
headers.insert("x-api-key", key);
Ok(Self {
http: reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(5))
.build()?,
org_id: org_id.into(),
})
}
async fn post(&self, path: &str, mut body: Value) -> reqwest::Result {
// organizationId is required on nearly every endpoint and comes from the body,
// not from the key.
body["organizationId"] = json!(self.org_id);
self.http
.post(format!("{BASE}{path}"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await
}
pub async fn track(&self, session_id: &str, signals: Vec) -> reqwest::Result {
self.post("/track", json!({ "sessionId": session_id, "signals": signals })).await
}
pub async fn ranked_catalog(&self, session_id: &str, limit: u32) -> reqwest::Result {
self.post("/catalog/ranked", json!({ "sessionId": session_id, "limit": limit })).await
}
}
```
```rust
let pulse = Pulse::new(&std::env::var("PULSE_API_KEY")?, std::env::var("PULSE_ORG_ID")?)?;
// Ranking is an enhancement. Fall back to catalogue order rather than failing the response.
let ranked = pulse.ranked_catalog(&session_id, 24).await.unwrap_or_else(|_| json!({ "items": [] }));
```
It keeps the value out of `Debug` output for the header map, which is the kind of log line
that ends up in an error report without anyone deciding to put it there.
# Documentation changelog (/changelog)
What changed on this site, and when. The list is read from the repository's own history when the
site is built, so it cannot drift from what actually happened and nobody has to remember to
update it.
The API's compatibility rules, what counts as a breaking change and what we owe you when one
happens, are on [Versioning](/api/versioning). **The API is versioned by URL and these guides
are not**, which is a deliberate choice explained there. This page is the history of the
documentation itself.
## Recent changes [#recent-changes]
## Reading a page's own history [#reading-a-pages-own-history]
Every page carries the date it last changed at the foot of the page, beside a link to that one
file's full commit history. That is the quickest way to answer "did this page say something
different last month", which is usually the real question.
The pages are MDX files in
[`content/docs`](https://github.com/mypulseapi/handbook/tree/main/content/docs), so the history is
ordinary git: every revision, who made it, and what else changed in the same commit.
# How inference works (/concepts/how-it-works)
Most search and recommendation tools answer, because answering is all they can do. They rank
something for any query, including the queries your catalogue has no data to resolve.
PULSE does two things those do not.
} title="Infers in-session" description="Persona accumulates from behaviour within a single visit, with no identity and no history." />
} title="Knows when it cannot answer" description="If the catalogue cannot resolve a requirement, that is a reportable fact rather than a bad ranking." />
## The loop [#the-loop]
### A signal arrives [#a-signal-arrives]
A category view, a price filter, a device class. See [Signals](/concepts/signals).
### The persona updates [#the-persona-updates]
Each signal moves a set of persona scores. Scores decay, so a session that changes direction
is followed rather than averaged.
### Products are scored against it [#products-are-scored-against-it]
`search` ranks products you pass in. `rankedCatalog` ranks the catalogue already synced to PULSE.
### Exposure is recorded [#exposure-is-recorded]
What was actually shown is what makes ranking quality measurable later. See
[`exposure`](/api/v1/exposure).
## What it is not [#what-it-is-not]
PULSE is not a PIM and it replaces nothing you already run. It reads your catalogue and ranks
against it. If a field is missing everywhere, ranking cannot invent it, and saying so is the
point.
# Sessions (/concepts/sessions)
A session is the unit everything attaches to. It is created by `init()` and held in browser
storage.
```ts
pulse.getSessionId(); // 'sess_...'
pulse.resetSession(); // start clean
await pulse.getSession(); // current personas, server side
```
## Lifetime [#lifetime]
| | Default | |
| --- | --- | --- |
| `sessionTtlMs` | 30 minutes | Inactivity before a new session starts |
## Watching the persona change [#watching-the-persona-change]
```ts
pulse.onPersona((update) => {
console.log(update.primary, update.confidence);
});
```
Useful during integration: if personas never move, the signals are not arriving, and that is
visible in two lines rather than in a ranking that looks vaguely wrong.
# Signals (/concepts/signals)
A signal is one observation about the current session. Send them with
[`track`](/api/v1/track).
```ts
await pulse.track([
{ type: 'category_view', value: 'helmets-safety' },
{ type: 'product_view', value: 'sku-123' },
{ type: 'price_filter', value: '500000-1500000' },
]);
```
## Captured for you [#captured-for-you]
`init()` sends these once per session, unless `autoTrack: false`:
| | |
| --- | --- |
| **Device** | Class and capability, parsed from the user agent |
| **Referrer** | Where the session came from |
| **Time of day** | Local hour bucket |
They cost no network beyond the one call, and they are exported individually so a host app can
re-assert them on a later page load:
```ts
import { captureDeviceSignal, captureReferrerSignal, captureTimeSignal } from '@thepulseapi/sdk';
```
## What is not a signal [#what-is-not-a-signal]
`anonymousId` is an identity label. Nothing in the engine matches on it, so labelling a session
cannot change what is inferred about it. That separation is deliberate: it makes it possible to
attribute a session without contaminating the result.
Age, gender, job title and income are **not** inputs. The engine reads behaviour. If you hold
those attributes, they are ground truth to grade against, not features to feed in.
# What the audit measures (/concepts/what-the-audit-measures)
The audit returns attributes in priority order. The order is the product, and it is not the order
you would get by counting blanks.
## Why not just fix the emptiest column? [#why-not-just-fix-the-emptiest-column]
Because emptiness and usefulness are different quantities, and on a real shelf they invert.
Here are two attributes from the 216-product laptop catalogue we use for our own research:
| Attribute | Missing on | Rank by emptiness | Rank by decision value |
| --- | --- | --- | --- |
| Processor | 162 of 216 | **1st** | **last** |
| Screen size | 80 of 216 | 4th | **1st** |
Processor is missing twice as often and almost nobody is deciding on it: it appears in 2 of the 32
buying requirements we tested. Screen size appears in 13. Fill processor everywhere and you have
done the most visible work on the dashboard and moved the fewest decisions.
A completeness score measures your catalogue against itself. The audit measures it against what
shoppers are trying to decide. Those produce different work orders, and only one of them is
ranked by what it buys.
## What is a bit of decision information? [#what-is-a-bit-of-decision-information]
One bit is one halving of the shelf.
If a shopper faces 216 products and an attribute splits them cleanly in two, that attribute is
worth one bit: it takes 216 to 108. Two bits takes it to 54. The audit reports what each attribute
would be worth **if it were recorded everywhere**, minus what it is worth today, and the
difference is what the repair buys.
This is why the number is not a percentage. A percentage tells you how full a column is. Bits tell
you how much closer a shopper gets to a decision when you fill it.
## Where does the missing value already live? [#where-does-the-missing-value-already-live]
Often in text you already own. Before recommending that anyone type anything, the audit tries to
recover the value from your own titles and descriptions.
On that same laptop shelf, **161 of 216 missing screen sizes were already sitting in the product
titles**. That is a parse, not a procurement project, and it changes what the repair costs enough
to change whether it is worth doing.
The audit reports each attribute's provenance so you can tell the two apart:
| Where the fact is | What the repair actually is |
| --- | --- |
| Already in your text | Extraction. Cheap, and often same-day |
| In a supplier feed | Mapping. A ticket, not a project |
| Nowhere | Acquisition. Someone has to go and find it |
## When is the ranking wrong? [#when-is-the-ranking-wrong]
Three cases, and we would rather you heard them from us.
### The recorded values are not representative [#the-recorded-values-are-not-representative]
Simulating a complete catalogue can only draw on values your shelf already holds. If an attribute
is recorded on twenty products and those twenty are not typical of the range, the simulation is
wrong in a way we can name and cannot yet correct.
### The requirements are not yours [#the-requirements-are-not-yours]
The buying requirements we test against are typed from public category guides and filter rails. If
your shoppers want something those do not cover, the ranking will not know. Sending us your own
search and filter logs is what fixes this, and it is the single highest-value thing you can add.
### The fact exists only in a photograph [#the-fact-exists-only-in-a-photograph]
A dietary claim printed on a pack, a material visible in an image. We can tell you the attribute
is missing from your fields. We cannot yet read it out of your imagery, and we will say so on the
row rather than quietly scoring it as absent.
## What happens next? [#what-happens-next]
} title="Run it" href="/api/v1/audit" description="POST /catalog/audit and /catalog/verify, with the request and response shapes." />
} title="The runtime" href="/concepts/how-it-works" description="The other half: ranking a live session once the catalogue can support it." />
# Alpine (/frameworks/alpine)
Alpine is attribute-driven, so the script-tag build is the natural fit. No bundler, no install.
```html title="index.html"
```
The global `Pulse` **is** the client, so call it directly from Alpine:
```html
```
There is no default export on the global. `Pulse.init` resolves; `Pulse.default.init` does not.
# Angular (/frameworks/angular)
```ts title="src/app/pulse.service.ts"
import { Injectable } from '@angular/core';
import { PulseClient } from '@thepulseapi/sdk';
@Injectable({ providedIn: 'root' })
export class PulseService extends PulseClient {}
```
Initialise from `APP_INITIALIZER`, or from the first component that runs in the browser:
```ts title="src/app/app.config.ts"
import { APP_INITIALIZER } from '@angular/core';
import { PulseService } from './pulse.service';
export const appConfig = {
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [PulseService],
useFactory: (pulse: PulseService) => () =>
pulse.init({ apiKey: 'pk_live_...', organizationId: 'org_...' }),
},
],
};
```
# Astro (/frameworks/astro)
Astro ships whichever island framework you already use, so for islands follow
[React](/frameworks/react) or [Vue](/frameworks/vue).
For a plain Astro page, this is the whole integration:
```astro title="src/layouts/Base.astro"
```
Or skip the install entirely and use the CDN build:
```astro
```
# Overview (/frameworks)
The client is plain TypeScript with no runtime dependencies and no framework coupling. Every
browser API it touches sits behind a `typeof` guard, so importing it during server rendering is
safe. Only `init()` needs a browser.
} title="React and Next.js" href="/frameworks/react" description="Provider and hooks. SSR-safe." />
} title="Vue and Nuxt" href="/frameworks/vue" description="Plugin and provide/inject." />
} title="Angular" href="/frameworks/angular" description="An injectable service." />
} title="Astro" href="/frameworks/astro" description="Islands or a script tag." />
} title="Alpine" href="/frameworks/alpine" description="The global, straight from HTML." />
} title="Laravel" href="/frameworks/laravel" description="Blade partial, keys stay server-side." />
Astro, Alpine and Laravel need no adapter at all. Astro ships whichever island framework you
already use, and the other two are a script tag.
# Laravel (/frameworks/laravel)
Laravel renders on the server, so the browser half is a script tag, usually in a layout.
```php title="config/services.php"
'pulse' => [
'key' => env('PULSE_PUBLISHABLE_KEY'),
'org' => env('PULSE_ORGANIZATION_ID'),
],
```
```blade title="resources/views/partials/pulse.blade.php"
```
```blade title="resources/views/layouts/app.blade.php"
@include('partials.pulse')
```
The publishable `pk_live_` key is designed to be visible in browser code. Anything that
administers your account is not. Use `@json()` rather than string interpolation so a value
containing a quote cannot break out of the script.
## Calling from PHP [#calling-from-php]
To talk to PULSE from PHP rather than the browser, use the [REST API](/api/v1) directly. There
is no PHP package yet.
# React and Next.js (/frameworks/react)
## A provider [#a-provider]
```tsx title="app/pulse-provider.tsx"
'use client';
import { createContext, useContext, useRef } from 'react';
import { PulseClient } from '@thepulseapi/sdk';
const PulseContext = createContext(null);
export const usePulse = () => useContext(PulseContext)!;
export function PulseProvider({ children }: { children: React.ReactNode }) {
const pulse = useRef(null);
pulse.current ??= new PulseClient();
useEffect(() => {
pulse.current!.init({
apiKey: process.env.NEXT_PUBLIC_PULSE_API_KEY!,
organizationId: process.env.NEXT_PUBLIC_PULSE_ORG_ID!,
});
}, []);
return {children};
}
```
The component that calls `init()` must be a **client component**, because the session lives in
the browser. Server Components and SSR are otherwise fine: the import is side-effect free and
every browser API is guarded.
## Using it [#using-it]
```tsx title="components/product-card.tsx"
'use client';
import { usePulse } from '@/app/pulse-provider';
export function ProductCard({ sku }: { sku: string }) {
const pulse = usePulse();
return (
);
}
```
# Vue and Nuxt (/frameworks/vue)
```ts title="plugins/pulse.ts"
import { PulseClient } from '@thepulseapi/sdk';
import type { App } from 'vue';
export default {
install(app: App) {
const pulse = new PulseClient();
// Nuxt renders on the server first; init() is browser-only.
if (!import.meta.env.SSR) {
pulse.init({ apiKey: 'pk_live_...', organizationId: 'org_...' });
}
app.provide('pulse', pulse);
},
};
```
```vue title="components/ProductCard.vue"
```
# Going live (/going-live)
You have run an audit, repaired something, and possibly wired up the runtime. This page is the
end of the path: what to check before it goes in front of shoppers, and what to expect
afterwards.
## What should I check before shipping? [#what-should-i-check-before-shipping]
### Keys are the right kind [#keys-are-the-right-kind]
A publishable key (`pk_live_...`) is designed to be visible in page source. A secret key is not,
and belongs only in a server guide. If a secret key reached a theme file or a client bundle,
revoke it now rather than after the deploy.
### A held-back group exists [#a-held-back-group-exists]
Decide what fraction of traffic never sees PULSE ranking, and keep it. Without a control arm you
cannot tell a good quarter from a good ranker, and you will not be able to answer the first
question your finance team asks.
### Your own order is the fallback [#your-own-order-is-the-fallback]
If the engine is slow or unavailable, the page should render your existing order rather than an
empty shelf. Check it by pointing `endpoint` at a port nothing is listening on and loading the
page.
### `init()` failure is visible to you [#init-failure-is-visible-to-you]
`init()` sends the automatic signals exactly once and swallows its own error. A session whose
`init()` failed stays thin for its whole life, and nothing in the page tells you. Log the
failure, or call
[`captureAutoSignals()`](/sdk#beyond-the-client) again on a later page load.
### Exposure is recorded [#exposure-is-recorded]
If you rank, call `exposure()` with what was actually rendered. A ranking you cannot tie to what
the shopper saw cannot be evaluated later, and the buffered call costs almost nothing.
## What should I watch in the first week? [#what-should-i-watch-in-the-first-week]
| Watch | Why it matters |
| --- | --- |
| Sessions with zero signals | Almost always `init()` failing silently, or a key scoped to the wrong origin |
| Abstention rate | The engine declining to rank is working as designed. A sudden move in it usually means the catalogue changed, not the engine |
| Audit readiness, re-run | Catalogues decay. New products arrive unrecorded, and a shelf that scored well last quarter quietly stops |
| Fallback rate | How often your own order was served instead. Should be near zero and is not always |
## What will you not claim for me? [#what-will-you-not-claim-for-me]
Worth reading before you repeat anything from this site internally.
We have run no live revenue experiment. We will not quote a conversion lift, a return-rate
reduction or an ROI multiple, and any figure you see elsewhere attached to those words did not
come from us.
What we can show is a property of your catalogue: which requirements it can and cannot resolve,
before and after a repair. That is measured, it is reproducible, and it is not the same thing as
a sale.
Three more limits, stated plainly:
- **A fact that exists only inside a photograph is one we can tell you is missing and cannot yet
recover for you.** Extracting attributes from product imagery is research we are doing, not a
feature we ship.
- **Our simulation of a complete catalogue can only use values your shelf already records.**
Where an attribute's recorded values do not represent its true range, the simulation is wrong
in a way we can name and cannot yet correct.
- **We have no customers to name.** When that changes we will say so with their permission.
## Where does this go next? [#where-does-this-go-next]
} title="API reference" href="/api/v1" description="Every endpoint a publishable key can call, with the error shapes." />
} title="SDK reference" href="/sdk" description="Every method on the client, and the functions beside it." />
} title="How inference works" href="/concepts/how-it-works" description="What the engine measures, and when it is wrong." />
# Introduction (/)
Most tools here rank what a shopper sees. PULSE asks the question underneath that one: can your
catalogue answer what they came to find out at all? You send a category export, get back the
attributes worth recording in priority order, and re-measure once your team has filled them in.
Nothing goes on your site to begin with. The first useful answer comes from a file.
PULSE is made by **Resolve Labs**, a small team working on one question: which product
information is worth paying to fix. The marketing site is
[thepulseapi.com](https://thepulseapi.com/en). This site is for the people who have to wire it
up.
## What problem does this actually solve? [#what-problem-does-this-actually-solve]
A shopper leaves without buying. Either they could not find the product, or your catalogue could
not tell them apart.
The second one is larger and almost nobody measures it. On a 216-product laptop shelf we
measured, **94 products remain indistinguishable** after every question a shopper could answer,
because the attributes that would separate them are not recorded anywhere. No ranking model fixes
that. Only recording the attribute does.
So PULSE starts at the catalogue rather than at the session:
| The question | What PULSE answers with |
| --- | --- |
| **Which missing field costs the most?** | A ranking by how many shoppers need it, not by how empty the column is |
| **Where does that fact already live?** | Often in your own titles and descriptions, recoverable before anyone types anything |
| **Did the repair work?** | The same measurement on the repaired export, with no traffic needed |
## Where should I start? [#where-should-i-start]
}
title="You came from thepulseapi.com"
href="/quickstart"
description="You have read what the audit does. This is how to run one yourself, in about ten lines."
/>
}
title="You landed here first"
href="/concepts/how-it-works"
description="Start with what the engine measures and why the number means anything."
/>
## What does the whole journey look like? [#what-does-the-whole-journey-look-like]
Six steps, in the order you will meet them. Each one is useful on its own, and you can stop
after any of them.
### Measure the catalogue [#measure-the-catalogue]
`POST /catalog/audit` takes a category export and returns the attributes worth recording, ranked,
with the affected products attached. Stateless, and it stores nothing unless you ask it to.
[Run your first audit](/quickstart)
### Understand what came back [#understand-what-came-back]
The ranking is measured in bits of decision information rather than guessed from clicks, and the
emptiest column is usually the wrong one to fix. This explains what that means and, more
usefully, the three cases where it is wrong.
[What the audit measures](/concepts/what-the-audit-measures)
### Repair, then verify [#repair-then-verify]
Your team populates the top attribute. `POST /catalog/verify` runs the same measurement on the
repaired export and reports what moved, attribute by attribute. It reads your catalogue rather
than your traffic, so there is nothing to wait for.
[Verify endpoint](/api/v1/audit)
### Put it in your stack [#put-it-in-your-stack]
Only needed once you want the live parts. Framework guides for React, Vue, Angular, Astro, Alpine
and Laravel; storefront guides for Shopify, WordPress and Magento; server guides for eight
languages.
[Framework guides](/frameworks) · [Storefronts](/platforms) · [Backend](/backend)
### Rank live sessions, if you want to [#rank-live-sessions-if-you-want-to]
The optional part, and the only part that touches your storefront. The SDK sends signals, the
engine ranks your catalogue against what the session implies, and every score comes with a
per-factor breakdown.
[SDK reference](/sdk) · [API reference](/api/v1)
### Go live [#go-live]
What to check before you ship, what to watch afterwards, and the things we will not claim.
[Going live](/going-live)
## Install [#install]
Or drop it on the page, which is what the storefront integrations use:
```html title="index.html"
```
The script tag exposes the client as the global `Pulse`, so `Pulse.init(...)` works directly.
Signals and a session id. Not names, not emails, not payment data. The audit never sees a
shopper at all: it reads product data.
## What should I know before trusting the numbers? [#what-should-i-know-before-trusting-the-numbers]
} title="3.2 KB gzipped" description="The script-tag build, with zero runtime dependencies." />
} title="No history required" description="The audit needs one export. The runtime ranks from the first visit." />
} title="MIT licensed" description="The client is open. Read every line before you ship it." />
} title="No uplift claim" description="We have run no live revenue experiment, so we quote no conversion figure." />
# MCP for agents (/mcp)
Every page on this site is available to an agent over the Model Context Protocol. An assistant
with this connected searches the documentation and quotes it, instead of recalling an endpoint
that may not exist.
## How do I connect it? [#how-do-i-connect-it]
Or point your client at it directly, with no install:
```json title="claude_desktop_config.json"
{
"mcpServers": {
"pulse-docs": {
"command": "npx",
"args": ["-y", "@thepulseapi/docs-mcp"]
}
}
}
```
The same block works in Claude Code (`.mcp.json`), Cursor and anything else that speaks MCP over
stdio.
This site is a static export, so there is no server here to host an HTTP endpoint on. The
package runs wherever your agent runs and fetches the published documentation index, which
means nothing about your prompts or your code reaches us.
## What tools does it expose? [#what-tools-does-it-expose]
| Tool | What it does |
| --- | --- |
| `search_docs` | Full-text search across every page. Returns the matching section, its URL and a score |
| `get_page` | One page as Markdown, by path, such as `/quickstart` or `/api/v1/audit` |
| `list_pages` | Every page with its title, for orienting before a search |
All three take a `lang` of `en` or `vi`.
## Why is the search lexical rather than semantic? [#why-is-the-search-lexical-rather-than-semantic]
The website embeds your question with a model it has already downloaded. Making an agent download
one to ask a question is a poor trade, because the agent is itself a language model: it can read
three candidate sections and decide which is right far better than a cosine can.
It also matters that an agent asking about `/catalog/audit` wants the page containing that exact
string, and lexical scoring is precise about that where an embedding blurs it.
## What if I want it pointed somewhere else? [#what-if-i-want-it-pointed-somewhere-else]
Set `PULSE_DOCS_ORIGIN` to serve from a different origin, which is mostly useful when you are
working on the documentation itself:
```bash
PULSE_DOCS_ORIGIN=http://localhost:3000 npx @thepulseapi/docs-mcp
```
## Can I just read the Markdown? [#can-i-just-read-the-markdown]
Yes, and you do not need MCP for it.
| | |
| --- | --- |
| [`/llms.txt`](/llms.txt) | Every page, titled and linked, in one file |
| [`/llms-full.txt`](/llms-full.txt) | The whole documentation as one Markdown document |
| `/llms.mdx/docs//content.md` | Any single page as Markdown |
| [`/search-index.json`](/search-index.json) | The chunked corpus with its embeddings |
Each page also carries a **Copy Markdown** button, which is the fastest route when you just want
to paste one page into a conversation.
# Overview (/platforms)
Not in the Shopify App Store, not on the WordPress plugin directory, not in the Magento
Marketplace. Anyone telling you otherwise is describing a plan. What exists today is the
script tag, and it works on all three.
That is less of a limitation than it sounds. PULSE needs three things from a storefront: the
script, an `init` call with your keys, and signals describing what the shopper is looking at.
Every platform here can supply all three from its own theme templates, which is also the only
place that knows what page the shopper is on.
} title="Shopify" href="/platforms/shopify" description="theme.liquid, with signals from Liquid objects." />
} title="WordPress and WooCommerce" href="/platforms/wordpress" description="wp_enqueue_script, with signals from the WooCommerce conditionals." />
} title="Magento 2" href="/platforms/magento" description="Layout XML and a template block." />
## What an app would add [#what-an-app-would-add]
Worth being precise about what you give up by pasting a script instead:
| | Script tag, today | An app or plugin |
| --- | --- | --- |
| Install | Paste into a theme file | One click, no theme edit |
| Keys | Typed into the theme | Entered in an admin screen |
| Survives a theme change | No | Yes |
| Catalogue sync | You send products per request | Pushed and kept current |
| Signals | You write them per template | Mapped for you |
The first four rows are convenience. The fifth is where the difference actually lives, and it is
also why a plugin is not just packaging: a good one knows what a "collection page" means on that
platform and sends the right signal without being told.
## Other platforms [#other-platforms]
Anything that lets you add a script to a page can run the browser SDK, and anything that can
make an HTTPS request can call the API directly. If your platform is not listed, the
[Alpine guide](/frameworks/alpine) is the closest shape: a script tag and HTML attributes, no
build step. For server-side work, see [Backend](/backend).
# Magento 2 (/platforms/magento)
No PULSE extension exists. This is a small module you add yourself, in the shape Magento
expects.
Magento does not take a pasted script tag. Markup belongs to a template, templates are attached
by layout XML, and both live in a module. That is three files before anything runs, which is the
platform rather than PULSE.
## The module [#the-module]
```php title="app/code/Pulse/Tracking/registration.php"
```
## Loading the SDK [#loading-the-sdk]
`default.xml` applies to every page, which is what you want for `init`.
```xml title="app/code/Pulse/Tracking/view/frontend/layout/default.xml"
```
`src_type="url"` is load bearing. Without it Magento treats the path as a local static asset and
tries to resolve it inside `pub/static`, where it does not exist.
```php title="app/code/Pulse/Tracking/view/frontend/templates/init.phtml"
```
## Page signals [#page-signals]
Category and product pages each get their own layout handle, so each can attach its own block
without any conditional logic.
```xml title="view/frontend/layout/catalog_category_view.xml"
```
The equivalent file for products is `catalog_product_view.xml`. Each template sends one `track`
call built from the registry object its block resolves.
Hard-coding them in a template means a deployment to change a key. A `system.xml` field read
through `ScopeConfigInterface` puts them in the admin, which is also where a store operator
expects to find them.
# Shopify (/platforms/shopify)
This is a theme edit. It does not survive switching themes, and it puts your keys in a theme
file that anyone with theme access can read. Publishable keys are designed to be visible in
page source, so that is a housekeeping problem rather than a security one.
## 1. Add the script [#1-add-the-script]
**Online Store → Themes → Edit code → `layout/theme.liquid`**, just before ``:
```liquid title="layout/theme.liquid"
```
The script tag exposes the client as the global `Pulse`. `async` means it may not have loaded
when inline script runs, which is why `init` goes inside a `load` listener rather than being
called immediately.
## 2. Send signals from the templates [#2-send-signals-from-the-templates]
Liquid already knows what the shopper is looking at. Each template sends what it knows.
```liquid title="sections/main-collection-banner.liquid"
```
```liquid title="sections/main-product.liquid"
```
`{{ product.vendor | json }}` escapes the value properly. Wrapping it in your own quotes breaks
on the first vendor with an apostrophe in the name, and does it only for that vendor, which is
the kind of bug that reaches production.
## 3. Record outcomes [#3-record-outcomes]
Shopify fires its own events. Forward the ones that mean something.
```liquid title="layout/theme.liquid"
```
The event name varies by theme: Dawn and its descendants publish `cart:item-added`, older themes
publish their own. Check what your theme dispatches before trusting this one.
## What is not covered [#what-is-not-covered]
Checkout. Shopify does not let a theme script run on checkout pages outside Plus, so purchase
outcomes have to come from your server via a webhook, not from the browser. See
[Backend](/backend) for the call to make when the webhook arrives.
# WordPress and WooCommerce (/platforms/wordpress)
There is no PULSE plugin to install. What follows is a short one you write yourself, which is
perhaps twenty lines and does not need to be anything more.
## Write it as a plugin, not a theme edit [#write-it-as-a-plugin-not-a-theme-edit]
Put it in `wp-content/plugins/pulse-tracking/pulse-tracking.php`. A theme edit disappears the
next time the theme updates; a plugin does not, and a child theme is more work than this.
```php title="wp-content/plugins/pulse-tracking/pulse-tracking.php"
get_id(), 'product_cat');
$signals = [['type' => 'product_view', 'value' => (string) $product->get_id()]];
if (is_array($terms) && $terms) {
$signals[] = ['type' => 'category_view', 'value' => $terms[0]->slug];
}
return $signals;
}
if (is_product_category()) {
$term = get_queried_object();
return [['type' => 'category_view', 'value' => $term->slug]];
}
if (is_search()) {
return [['type' => 'search', 'value' => get_search_query()]];
}
return [];
}
```
It is WordPress's `json_encode` with the flags already set for embedding in HTML. Building the
JavaScript by string concatenation breaks on the first product category with a quote in its
name, and only for that category.
## Outcomes [#outcomes]
```php
add_action('woocommerce_add_to_cart', function ($cart_item_key, $product_id) {
// Fires server-side, so this records the outcome where it actually happened
// rather than guessing from a click in the page.
wp_add_inline_script('pulse-sdk', sprintf(
'Pulse.event("add_to_cart", { productId: %s });',
wp_json_encode((string) $product_id)
), 'after');
}, 10, 2);
```
For a purchase, `woocommerce_thankyou` runs on the order received page and has the order, which
is the right place to record revenue. Orders that complete off-site need the
[server-side call](/backend/php) instead.
# Quick start (/quickstart)
### Get your keys [#get-your-keys]
Sign in to the [dashboard](https://app.thepulseapi.com) and open **Integration → API keys**.
Create a key, name it for where it will run, and copy it immediately.
The full key is returned only at creation. Only its hash is stored. If you lose it, revoke it
and create another.
Your **organization id** is on the same page. You need both.
### Install [#install]
### Start a session [#start-a-session]
```ts title="pulse.ts"
import { PulseClient } from '@thepulseapi/sdk';
export const pulse = new PulseClient();
await pulse.init({
apiKey: 'pk_live_...',
organizationId: 'org_...',
});
```
`init()` also captures device, referrer and time of day once per session, unless you pass
`autoTrack: false`.
### Send what the shopper did [#send-what-the-shopper-did]
```ts
await pulse.track([
{ type: 'category_view', value: 'helmets-safety' },
{ type: 'price_filter', value: '500000-1500000' },
]);
```
### Rank your catalogue [#rank-your-catalogue]
```ts
const { results } = await pulse.search(products);
results[0].scoreBreakdown; // why this one came first
```
Every result carries the signals that matched it, so you can show a shopper why, or show
yourself why when it looks wrong.
## Without a bundler [#without-a-bundler]
```html title="index.html"
```
# SDK reference (/sdk)
## Session [#session]
| Method | Returns | |
| --- | --- | --- |
| `init(config)` | `TrackResponse \| null` | Start a session and send the automatic signals once |
| `getSession()` | `SessionResponse` | Current session and inferred personas |
| `getSessionId()` | `string` | The current session id |
| `resetSession()` | `void` | Clear it and start fresh |
| `onPersona(cb)` | `void` | Subscribe to persona updates |
## Signals and ranking [#signals-and-ranking]
| Method | Returns | |
| --- | --- | --- |
| `track(signals)` | `TrackResponse` | Send shopper signals |
| `search(products, options?)` | `SearchResponse` | Rank products you pass in |
| `rankedCatalog(options?)` | `RankedCatalogResponse` | Rank the catalogue already synced to PULSE |
| `event(type, data?)` | `EventResponse` | Record a named event |
`search` and `rankedCatalog` are not interchangeable. `search` ranks what you hand it;
`rankedCatalog` ranks what PULSE already holds.
## Exposure [#exposure]
| Method | | |
| --- | --- | --- |
| `exposure(decisionId, items)` | `void` | Record what was actually rendered, buffered |
| `flushExposures()` | `Promise` | Send buffered exposures now |
## Config [#config]
## Beyond the client [#beyond-the-client]
`PulseClient` is most of the package and not all of it. These are exported too, for the cases
where you need a signal without a client, or need to look at what the SDK cached.
### Signal capture [#signal-capture]
`init()` calls `captureAutoSignals()` for you when `autoTrack` is on, so you only need these
directly when you want a signal at a different moment, or want one the automatic set leaves out.
| Export | Returns | |
| --- | --- | --- |
| `captureAutoSignals()` | `Signal[]` | Device, referrer and time in one call. What `init()` sends |
| `captureDeviceSignal()` | `Signal \| null` | Device class from the user agent |
| `captureReferrerSignal()` | `Signal \| null` | Where the visit came from |
| `captureTimeSignal()` | `Signal` | Time of day. Never null, there is always a clock |
| `captureEnhancedReferrerSignals()` | `Signal[]` | Campaign and channel detail beyond the plain referrer |
| `captureDeviceInfoAsync(): Promise` | `DeviceInfo` | Fuller device detail, including client hints |
| `captureGeoInfo(): Promise` | `GeoInfo \| null` | Coarse location from the IP |
| `geoToSignals(geo)` | `Signal[]` | Turn a `GeoInfo` into signals |
| `captureWeatherInfo(lat, lon, apiKey?): Promise` | `WeatherInfo \| null` | Conditions at a point |
| `weatherToSignals(weather)` | `Signal[]` | Turn a `WeatherInfo` into signals |
| `captureCulturalCalendar(countryCode)` | `Signal[]` | Seasonal and cultural events active today |
| `parseUserAgent(ua)` | `DeviceInfo` | Parse a user agent string you already have |
It calls OpenWeatherMap and returns `null` without an `apiKey`, so nothing breaks if you leave
it out. The free tier is 1,000 calls a day, which is why the result is cached per session
rather than fetched per page.
`captureAutoSignals()` is exported for one specific failure. `init()` sends the automatic
signals exactly once and swallows its own error, so a session whose `init()` failed never
re-sends them and stays thin for its whole life. These signals cost no network of their own, so
calling this again on a later page load is cheaper than the alternative.
### Session context [#session-context]
Device, location and weather are properties of the session rather than of the page: they cannot
change between one page and the next. The SDK captures them once, keys the cache by session id so
it cannot outlive the visitor it describes, and reuses it. You rarely need to touch this.
| Export | Returns | |
| --- | --- | --- |
| `readSessionContext(sessionId)` | `SessionContext \| null` | The cache, or null if it belongs to another session |
| `writeSessionContext(sessionId, patch)` | `void` | Merge fields into it |
| `isWeatherFresh(ctx, now?)` | `boolean` | Whether the weather reading is still inside its TTL |
| `clearSessionContext()` | `void` | Drop the cache |
| `WEATHER_TTL_MS` | `number` | 10 minutes |
Weather is the only field here with a TTL. Everything else is immutable for the session, but a
30-minute session can outlast a rain shower, and a `weather:rain` signal driving a
recommendation after the sun came out is the engine being confidently wrong.
# What PULSE offers (/vi/api)
PULSE publishes **two separate interfaces**, and they are not the same size.
} title="The HTTP API" description="Twelve endpoints. One header. Callable from a browser, a server, a cron job or curl. This is the product." />
} title="The browser SDK" description="A convenience layer over six of them, which also manages the session id, buffers exposures and captures device and referrer for you." />
`@thepulseapi/sdk` has a method for six of the twelve endpoints. Everything else, including
the Decision Audit, is HTTP only. If you are evaluating what PULSE can do, read this page
rather than the [SDK reference](/sdk).
## The product surface [#the-product-surface]
Every row below is reachable with a publishable key and the `x-api-key` header. Nothing here
needs a login.
| Endpoint | What it is for | Counts toward usage | SDK method |
| --- | --- | --- | --- |
| `POST /track` | Signals in, personas out | Yes | `track()` |
| `POST /track/behavior` | One named behaviour | Yes | none |
| `POST /track/keywords` | A raw search query, extracted for you | Yes | none |
| `POST /session` | The personas inferred so far | Yes | `getSession()` |
| `POST /catalog/ranked` | Rank the catalogue PULSE holds | Yes | `rankedCatalog()` |
| `POST /search` | Rank products you pass in | Yes | `search()` |
| `POST /rerank` | Reorder a result set from your own engine | Yes | none |
| `POST /suggest` | Query suggestions, scored on the session | **No** | none |
| `POST /catalog/audit` | What a catalogue cannot answer | Yes | none |
| `POST /catalog/verify` | What a repair actually changed | Yes | none |
| `POST /event` | A named outcome | **No** | `event()` |
| `POST /exposure` | What was actually rendered | **No** | `exposure()` |
### Why three of them are free [#why-three-of-them-are-free]
`/event` and `/exposure` are how you prove the ranking worked. Charging for measurement makes
switching it off the rational move, which blinds the learning loop and destroys the value case
in the same stroke.
`/suggest` fires on every keystroke. Metering it like `/search` would bill per character typed,
so someone spelling out a query would pay ten times for one search.
`/track` and `/track/behavior` are both billed as `track`, and `/catalog/ranked`,
`/catalog/audit` and `/catalog/verify` are all billed as `catalog`. Your invoice shows a line
per operation rather than one total, because `/search` costs real compute and `/session`
costs almost nothing.
## The four products, and the calls behind them [#the-four-products-and-the-calls-behind-them]
} title="Decision Audit" href="/api/v1/audit" description="POST /catalog/audit. Which missing field to record first, ranked, in your own catalogue." />
} title="Decision Verification" href="/api/v1/audit" description="POST /catalog/verify with repairs. Recompute after the work and see what moved." />
} title="Decision Monitor" href="/api/v1/audit" description="The same call without repairs. Catalogues decay; this is what notices." />
} title="Decision API" href="/api/v1" description="track, session, ranked, search, rerank, suggest. The live runtime." />
Audit, Verification and Monitor are **one computation over a diff**. There is no third endpoint
for Monitor because there is no third thing to compute: the only difference is whether anything
was claimed about the change.
## What is not on this page [#what-is-not-on-this-page]
There is a second, much larger API behind a **JWT**, not an API key: organizations, users,
roles, API keys, catalogue management, billing and subscriptions. That is the management plane
the [merchant portal](https://app.thepulseapi.com) is built on. It is not metered, it is not
sold as a product, and it is not documented here yet.
Routes exist that are not on this page. Some are internal, some are reachable only with a
login, and some resolve at the gateway and are not served downstream. If it is not written
down here, do not build on it.
# Audit a catalogue (/vi/api/v1/audit)
The ranking endpoints answer "which products first". These two answer a different question:
**can this catalogue answer the questions shoppers are asking of it at all.**
Both take a CSV as a string in the body rather than a file upload. An audit is a pure function
over text, so multipart would add a parser, a temporary file and a cleanup path for no gain at a
size where a category export is tens of kilobytes.
Neither call stores anything unless you ask it to.
## `POST /catalog/audit` [#post-catalogaudit]
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/catalog/audit" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"csv": "id,category,size,material\n1,helmet,L,abs\n2,helmet,,\n",
"source": "merchant",
"amortisationHorizon": 5000
}'
```
Without it the response carries no `action`. Comparing "ask the shopper" against "record the
fact once" needs to know how many shoppers the fact would serve, and picking a number on your
behalf would bury the assumption the whole comparison rests on.
## `POST /catalog/verify` [#post-catalogverify]
The same request does two jobs. Send `before` and `after` on their own and it is a monitor: what
changed between two exports, with nothing claimed. Add `repairs` and the same diff grades the
claims.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/catalog/verify" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"before": "id,category,size\n1,helmet,\n",
"after": "id,category,size\n1,helmet,L\n",
"repairs": [{ "attribute": "size", "method": "manual", "actualHours": 3 }],
"category": "Helmets"
}'
```
Monitor and verification are the same computation over the same diff. The only difference is
whether anything was claimed about it.
# Errors (/vi/api/v1/errors)
Every error has the same envelope.
```json title="401 Unauthorized"
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid or revoked API key"
},
"timestamp": "2026-10-19T08:27:01.722Z",
"path": "/api/v1/session",
"correlationId": "a4cc87c6bf160f30"
}
```
Quote it when you report a problem. It is how a single request is found in the logs, and it
turns "ranking looked wrong yesterday" into one lookup.
## Codes [#codes]
| Code | HTTP | What it usually means |
| --- | --- | --- |
| `BAD_REQUEST` | 400 | Malformed body, or an empty body with a JSON content type |
| `UNAUTHORIZED` | 401 | Missing, wrong, or revoked key. Check the header is `x-api-key` |
| `FORBIDDEN` | 403 | The key is valid but not for this organization's data |
| `NOT_FOUND` | 404 | No such route. Check the `/api/v1` prefix |
| `TOO_MANY_REQUESTS` | 429 | Rate limited at the gateway |
| `SERVICE_UNAVAILABLE` | 503 | A downstream service is failing fast behind its circuit breaker |
## Two that are worth knowing apart [#two-that-are-worth-knowing-apart]
A revoked or expired key returns 401 exactly like a typo. Keys can be given an expiry at
creation. Check the dashboard before debugging your code.
The base URL includes the version: `https://api.thepulseapi.com/api/v1`. A request to
`thepulseapi.com/v1/...` reaches the marketing site, not the API, and will not look like an
API error at all.
# Record exposure (/vi/api/v1/exposure)
Ranking cannot be graded without knowing what was actually shown. A click rate over results
nobody saw is not a measurement.
## `POST /exposure` [#post-exposure]
```ts
pulse.exposure(decisionId, items); // buffered
await pulse.flushExposures(); // send now
```
`exposure` buffers, because a product grid renders many items at once and one request per card
would be absurd. `flushExposures` sends what is pending, and the SDK also flushes on page hide.
If you asked for 24 and showed 12 above the fold, the honest exposure is what the shopper could
actually see. Over-reporting exposure makes your own ranking look worse than it is.
# Authentication (/vi/api/v1)
## Base URL [#base-url]
```bash
https://api.thepulseapi.com/api/v1
```
## The header [#the-header]
Authenticate with **`x-api-key`**. Not `Authorization: Bearer`.
```bash title="A real request"
curl -X POST "https://api.thepulseapi.com/api/v1/session" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{}'
```
## Getting a key [#getting-a-key]
Sign in to the [dashboard](https://app.thepulseapi.com), open **Integration → API keys**, and
create one.
| | |
| --- | --- |
| **Format** | Always `pk_live_` followed by base64url randomness |
| **Publishable** | Designed to be visible in browser code. It can send signals and read rankings, nothing else |
| **Scoped** | Belongs to one organization and cannot read another's data |
| **Shown once** | The full value is returned only at creation. Only the hash is stored |
| **Revocable** | Revocation is immediate |
Every issued key is `pk_live_`. There is no `pk_test_` and no sandbox environment yet. To keep
experiments out of your reporting, create a **separate key** for development and revoke it when
you are done.
## What a publishable key cannot do [#what-a-publishable-key-cannot-do]
Catalogue writes (`/products`, `/categories`) authenticate as a signed-in dashboard session, not
with a publishable key. They are not part of this reference.
# Rank products (/vi/api/v1/ranking)
## `POST /catalog/ranked` [#post-catalogranked]
Ranks the catalogue already synced to PULSE.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/catalog/ranked" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"limit": 24
}'
```
```ts
const { items } = await pulse.rankedCatalog({ limit: 24 });
```
## `POST /search` [#post-search]
Ranks products **you pass in**, against the session.
```ts
const { results } = await pulse.search(products, { limit: 24 });
results[0].scoreBreakdown; // the signals that matched, and their weights
```
## `POST /rerank` [#post-rerank]
Reorders an existing result set you already produced elsewhere, which is the lightest way to put
PULSE in front of a search engine you are keeping.
## `POST /suggest` [#post-suggest]
Query suggestions scored against the current session.
`search` and `rankedCatalog` are not interchangeable. One ranks what you hand it, the other
ranks what PULSE holds. Sending a catalogue you already synced to `search` is a wasted payload.
# Read the session (/vi/api/v1/session)
## `POST /session` [#post-session]
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/session" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{ "sessionId": "sess_abc123" }'
```
```ts
const session = await pulse.getSession();
```
Returns whether the session was found and the personas accumulated against it. A session id that
has never been written returns `found: false`, which is the quickest way to confirm your signals
are landing.
If `found` is false after a `track` call, the signals are not arriving. That is one request to
check, rather than reasoning backwards from a ranking that looks slightly wrong.
# Send signals (/vi/api/v1/track)
## `POST /track` [#post-track]
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/track" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"signals": [
{ "type": "category_view", "value": "helmets-safety" },
{ "type": "price_filter", "value": "500000-1500000" }
]
}'
```
With the SDK:
```ts
await pulse.track([
{ type: 'category_view', value: 'helmets-safety' },
{ type: 'price_filter', value: '500000-1500000' },
]);
```
Signals are additive. Send them as they happen rather than batching a whole session at the end:
persona scores decay, so ordering carries meaning.
Every endpoint below reads it from the body, not from the API key. Omitting it returns `400`
with a validation error naming the field. The SDK sends it for you from `init()`.
`/track` also captures device, referrer and time from the request itself, so a call with no
`signals` at all is still worth making: it opens the session with what the request already says.
## `POST /track/behavior` [#post-trackbehavior]
One behaviour, named, when you have nothing to say about a category or a price.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/track/behavior" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"behavior": "sort_price_asc",
"metadata": { "page": "category" }
}'
```
`behavior` is your own slug, capped at 128 characters. Sorting by price ascending says something
about a shopper that no category view does.
## `POST /track/keywords` [#post-trackkeywords]
A search query, turned into keyword signals for you.
```bash
curl -X POST "https://api.thepulseapi.com/api/v1/track/keywords" \
-H "Content-Type: application/json" \
-H "x-api-key: pk_live_..." \
-d '{
"sessionId": "sess_abc123",
"organizationId": "org_abc123",
"query": "mu bao hiem fullface"
}'
```
Send the raw query. Extraction happens server-side, and the query is capped at 512 characters
because the work is linear in its length.
## `POST /event` [#post-event]
A named event, for things that are not persona signals.
```ts
await pulse.event('checkout_started', { cartValue: 1250000 });
```
# Versioning (/vi/api/versioning)
## The API is versioned. The guides are not. [#the-api-is-versioned-the-guides-are-not]
| | Versioned | Why |
| --- | --- | --- |
| **API reference** | **Yes**, `/api/v1` | It is a contract. A request you wrote last year has to keep working, so the page describing it has to keep existing |
| Guides, concepts, frameworks | No | They describe behaviour, not a contract. "How inference works" has no v1 and v2 |
| SDK reference | No | The SDK follows the API. Where a method needs a minimum version, the page says so inline |
The version in the docs URL matches the version in the request URL:
```bash
https://api.thepulseapi.com/api/v1/track → /api/v1/track
```
There has never been another. The path carries the version anyway, because a URL is cheap to
choose now and expensive to change once people have linked to it.
## What counts as a breaking change [#what-counts-as-a-breaking-change]
A new major version happens only when an existing, correct request would start behaving
differently.
- Removing an endpoint, a field, or an accepted value
- Renaming anything in a request or response
- Making an optional request field required
- Narrowing a type, for example a string that becomes an enum
- Changing the meaning of a field while keeping its name. **This is the worst one**, because nothing fails loudly
- Adding a new endpoint
- Adding an optional request field
- Adding a field to a response. **Parse responses permissively**: unknown fields will appear
- Adding a new value to an output enum, unless the docs say the set is closed
- Making a validation message clearer
## What we owe you when a version changes [#what-we-owe-you-when-a-version-changes]
1. A new path, `/api/v1/v2`, with **v1 still published and still working**
2. A migration page naming every behavioural difference, not a summary
3. A deprecation notice on each v1 page pointing at its v2 equivalent
4. A stated end date for v1, not "eventually"
## The SDK versions separately [#the-sdk-versions-separately]
[`@thepulseapi/sdk`](https://www.npmjs.com/package/@thepulseapi/sdk) follows
[semver](https://semver.org) and tracks the API rather than mirroring it.
Pre-1.0, a **minor** version may contain a breaking change. That is what 0.x means in semver,
and it is worth pinning an exact version until 1.0.
The CDN build is versioned the same way, with an immutable path per release and a floating major:
```
https://cdn.thepulseapi.com/sdk/v0.1.5/pulse.iife.js exact, never changes
https://cdn.thepulseapi.com/sdk/v0/pulse.iife.js patches arrive here
```
# C# and .NET (/vi/backend/csharp)
No package to install. `HttpClient` and `System.Text.Json` are in the framework.
## A typed client [#a-typed-client]
```csharp title="PulseClient.cs"
using System.Net.Http.Json;
public sealed record Signal(string Type, string Value);
public sealed class PulseClient(HttpClient http, string organizationId)
{
public Task TrackAsync(string sessionId, IEnumerable signals) =>
PostAsync("track", new
{
sessionId,
organizationId,
signals = signals.Select(s => new { type = s.Type, value = s.Value }),
});
public Task RankedCatalogAsync(string sessionId, int limit = 24) =>
PostAsync("catalog/ranked", new { sessionId, organizationId, limit });
public Task SessionAsync(string sessionId) =>
PostAsync("session", new { sessionId });
private async Task PostAsync(string path, object body)
{
var response = await http.PostAsJsonAsync(path, body);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync();
}
}
```
## Registration [#registration]
```csharp title="Program.cs"
builder.Services.AddHttpClient(client =>
{
// The trailing slash matters. Without it, a relative path replaces the last segment
// and every request goes to https://api.thepulseapi.com/api/track instead.
client.BaseAddress = new Uri("https://api.thepulseapi.com/api/v1/");
client.DefaultRequestHeaders.Add("x-api-key", builder.Configuration["Pulse:ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(5);
});
```
`AddHttpClient` is what you want rather than `new HttpClient()`: it pools handlers and rotates
them, which is the difference between reusing connections and exhausting sockets under load.
```csharp
public class CatalogController(PulseClient pulse) : Controller
{
public async Task Category(string slug)
{
var sessionId = Request.Cookies["pulse_session"];
if (sessionId is null) return View(DefaultOrder(slug));
try
{
var ranked = await pulse.RankedCatalogAsync(sessionId);
return View(ranked);
}
catch (HttpRequestException)
{
// Ranking is an enhancement, never a reason to fail the page.
return View(DefaultOrder(slug));
}
}
}
```
`System.Text.Json` serialises anonymous object members verbatim, so `sessionId` stays
`sessionId`. If you swap in a named class with PascalCase properties, set
`JsonNamingPolicy.CamelCase` or the API will reject the body as missing its required fields.
# Go (/vi/backend/go)
```go title="pulse/pulse.go"
package pulse
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
const base = "https://api.thepulseapi.com/api/v1"
type Signal struct {
Type string `json:"type"`
Value string `json:"value"`
}
type Client struct {
apiKey string
orgID string
http *http.Client
}
func New(apiKey, orgID string) *Client {
return &Client{
apiKey: apiKey,
orgID: orgID,
// http.DefaultClient has no timeout at all, which is a goroutine leak waiting
// for a slow upstream. Always supply one.
http: &http.Client{Timeout: 5 * time.Second},
}
}
func (c *Client) post(ctx context.Context, path string, body map[string]any, out any) error {
// organizationId is read from the body rather than from the key, and is required
// on nearly every endpoint.
body["organizationId"] = c.orgID
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("pulse %s: %s", path, res.Status)
}
return json.NewDecoder(res.Body).Decode(out)
}
func (c *Client) Track(ctx context.Context, sessionID string, signals []Signal) (map[string]any, error) {
var out map[string]any
err := c.post(ctx, "/track", map[string]any{"sessionId": sessionID, "signals": signals}, &out)
return out, err
}
func (c *Client) RankedCatalog(ctx context.Context, sessionID string, limit int) (map[string]any, error) {
var out map[string]any
err := c.post(ctx, "/catalog/ranked", map[string]any{"sessionId": sessionID, "limit": limit}, &out)
return out, err
}
```
```go
p := pulse.New(os.Getenv("PULSE_API_KEY"), os.Getenv("PULSE_ORG_ID"))
ranked, err := p.RankedCatalog(r.Context(), sessionID, 24)
if err != nil {
// Serve the catalogue in its default order. Personalisation is an enhancement.
ranked = nil
}
```
Threading `r.Context()` through means a shopper who navigates away cancels the ranking call
instead of leaving it running against a response nobody will read.
# Overview (/vi/backend)
There is no PULSE library for your backend language, and you do not need one. Every endpoint is
`POST`, every body is JSON, and authentication is one header.
```
POST https://api.thepulseapi.com/api/v1/
Content-Type: application/json
x-api-key: pk_live_...
```
That is the whole integration surface. The guides in this section are the same three calls
written idiomatically per language, so you can copy one and delete the rest.
## Which calls belong on a server [#which-calls-belong-on-a-server]
Most of PULSE is designed for the browser, where the SDK already handles sessions, batching and
exposure buffering. Reach for the HTTP API directly when the work is genuinely server-side:
} title="Server-rendered pages" description="Ranking a grid during SSR, where there is no browser yet to hold a session." />
} title="Catalogue audits" description="A scheduled job grading an export. Nothing about it belongs in a page." />
} title="Backfills and pipelines" description="Replaying events you already collected, or recording outcomes your payment webhook knows about and the browser does not." />
## One thing to get right first [#one-thing-to-get-right-first]
`sessionId` and `organizationId` are required on nearly every call, and both come from the body
rather than from the API key. A request without them returns `400` with the field named. See
[Send signals](/api/v1/track).
A session is yours to name. It is a string you generate, up to 128 characters, and the only rule
is that the same shopper keeps the same one. If the browser is already running the SDK, read it
with `pulse.getSessionId()` and pass it to your server rather than inventing a second session for
the same person.
Keys are `pk_live_`, publishable, and the same key works from a browser and from a server.
There is no separate secret key today, which means a key leaked from your server is no worse
and no better than one read out of your page source. Scope and rotate accordingly.
## The languages [#the-languages]
} title="Python" href="/backend/python" description="requests or httpx, with Django and FastAPI notes." />
} title="C# and .NET" href="/backend/csharp" description="HttpClient, typed, with the DI registration." />
} title="Go" href="/backend/go" description="net/http and encoding/json. No dependencies." />
} title="Rust" href="/backend/rust" description="reqwest and serde." />
} title="Java" href="/backend/java" description="java.net.http, built in since 11." />
} title="Ruby" href="/backend/ruby" description="net/http from the standard library." />
} title="PHP" href="/backend/php" description="cURL, and Guzzle for Laravel." />
} title="Node.js" href="/backend/nodejs" description="The SDK runs server-side too." />
# Java (/vi/backend/java)
```java title="PulseClient.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
public final class PulseClient {
private static final String BASE = "https://api.thepulseapi.com/api/v1";
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final ObjectMapper json = new ObjectMapper();
private final String apiKey;
private final String organizationId;
public PulseClient(String apiKey, String organizationId) {
this.apiKey = apiKey;
this.organizationId = organizationId;
}
public JsonNode track(String sessionId, Object signals) throws Exception {
return post("/track", Map.of(
"sessionId", sessionId,
"organizationId", organizationId,
"signals", signals));
}
public JsonNode rankedCatalog(String sessionId, int limit) throws Exception {
return post("/catalog/ranked", Map.of(
"sessionId", sessionId,
"organizationId", organizationId,
"limit", limit));
}
private JsonNode post(String path, Map body) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("x-api-key", apiKey)
// connectTimeout above only covers establishing the connection. This is the
// one that bounds a server which accepted the socket and then went quiet.
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
.build();
HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300) {
throw new IllegalStateException("PULSE " + path + ": " + response.statusCode());
}
return json.readTree(response.body());
}
}
```
`HttpClient` is thread safe and pools connections, so build one and keep it. In Spring, that
means a `@Bean`, not a `new` inside a controller.
```java
@Bean
PulseClient pulseClient(
@Value("${pulse.api-key}") String apiKey,
@Value("${pulse.organization-id}") String organizationId) {
return new PulseClient(apiKey, organizationId);
}
```
`connectTimeout` on the client and `timeout` on the request do different jobs. Set only the
first and a server that accepts your connection and then stalls will hold the thread
indefinitely.
# Node.js (/vi/backend/nodejs)
`@thepulseapi/sdk` is isomorphic. The same package works in Node, so a server-rendered page can
use the client it already knows.
```ts title="pulse.server.ts"
import { PulseClient } from '@thepulseapi/sdk';
// One client per process. It holds no per-shopper state: the session id is passed per call.
export const pulse = new PulseClient();
pulse.init({
apiKey: process.env.PULSE_API_KEY!,
organizationId: process.env.PULSE_ORG_ID!,
autoTrack: false, // there is no browser here to capture a device or a referrer from
});
```
It exists to capture device, referrer and time from the browser. On a server those describe
your datacentre, and sending them pollutes the session with signals about a machine nobody
shopped from.
## Without the package [#without-the-package]
Node 18 and later ship `fetch`, so the whole integration is one function.
```ts title="pulse.ts"
const BASE = 'https://api.thepulseapi.com/api/v1';
async function post(path: string, body: Record): Promise {
const response = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.PULSE_API_KEY!,
},
body: JSON.stringify({ organizationId: process.env.PULSE_ORG_ID, ...body }),
// fetch has no default timeout, and a hung ranking call should not hold a request.
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`PULSE ${path}: ${response.status}`);
return response.json() as Promise;
}
export const track = (sessionId: string, signals: unknown[]) =>
post('/track', { sessionId, signals });
export const rankedCatalog = (sessionId: string, limit = 24) =>
post('/catalog/ranked', { sessionId, limit });
```
# PHP (/vi/backend/php)
For the browser half of a PHP app, see the [Laravel guide](/frameworks/laravel). This page is the
server half: calls your PHP makes itself.
## Plain PHP [#plain-php]
```php title="Pulse.php"
post('/track', ['sessionId' => $sessionId, 'signals' => $signals]);
}
public function rankedCatalog(string $sessionId, int $limit = 24): array
{
return $this->post('/catalog/ranked', ['sessionId' => $sessionId, 'limit' => $limit]);
}
private function post(string $path, array $body): array
{
// organizationId is read from the body rather than the key, and is required.
$body['organizationId'] = $this->organizationId;
$ch = curl_init(self::BASE . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $this->apiKey,
],
// JSON_THROW_ON_ERROR, so a body that failed to encode is an exception
// rather than the string "false" posted to the API.
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $status >= 300) {
throw new RuntimeException("PULSE {$path}: {$status}");
}
return json_decode($response, true, 512, JSON_THROW_ON_ERROR);
}
}
```
## Laravel [#laravel]
Guzzle is already there, wrapped as `Http`.
```php title="app/Services/Pulse.php"
config('services.pulse.key')])
->timeout(5)
->post('https://api.thepulseapi.com/api/v1/catalog/ranked', [
'sessionId' => $sessionId,
'organizationId' => config('services.pulse.org'),
'limit' => $limit,
])
->throw()
->json();
}
}
```
```php title="config/services.php"
'pulse' => [
'key' => env('PULSE_API_KEY'),
'org' => env('PULSE_ORG_ID'),
],
```
A ranking call inside a Blade template runs on every render with no timeout you control and no
way to fall back. Resolve it in the controller, pass the result in, and render catalogue order
when it fails.
# Python (/vi/backend/python)
Or `requests`, if it is already in the project. Both work; `httpx` is here because it gives you
the async client FastAPI wants without a second dependency.
## A client [#a-client]
```python title="pulse.py"
import os
import httpx
BASE = "https://api.thepulseapi.com/api/v1"
class Pulse:
"""Thin wrapper over the HTTP API. Every call is POST and returns JSON."""
def __init__(self, api_key: str, organization_id: str, timeout: float = 5.0):
self._org = organization_id
self._client = httpx.Client(
base_url=BASE,
timeout=timeout,
headers={"x-api-key": api_key, "Content-Type": "application/json"},
)
def _post(self, path: str, body: dict) -> dict:
# organizationId is required on nearly every endpoint and is read from the body,
# not from the key, so it is filled in here rather than at every call site.
response = self._client.post(path, json={"organizationId": self._org, **body})
response.raise_for_status()
return response.json()
def track(self, session_id: str, signals: list[dict]) -> dict:
return self._post("/track", {"sessionId": session_id, "signals": signals})
def session(self, session_id: str) -> dict:
# /session takes only sessionId. Sending organizationId is harmless.
return self._post("/session", {"sessionId": session_id})
def ranked_catalog(self, session_id: str, limit: int = 24, **kwargs) -> dict:
return self._post(
"/catalog/ranked", {"sessionId": session_id, "limit": limit, **kwargs}
)
pulse = Pulse(os.environ["PULSE_API_KEY"], os.environ["PULSE_ORG_ID"])
```
```python
pulse.track("sess_abc123", [{"type": "category_view", "value": "helmets-safety"}])
ranked = pulse.ranked_catalog("sess_abc123", limit=24)
```
## Django [#django]
Build the client once at module level, not per request. `httpx.Client` keeps a connection pool
and creating one per view throws that away.
```python title="catalog/views.py"
from django.shortcuts import render
from .pulse import pulse
def category(request, slug):
# The browser owns the session. Read it rather than minting a second one for the
# same shopper: two session ids means the inference starts from nothing twice.
session_id = request.COOKIES.get("pulse_session")
products = []
if session_id:
try:
products = pulse.ranked_catalog(session_id, limit=24, categorySlug=slug)["items"]
except Exception:
# Ranking is an enhancement. A page that fails to render because personalisation
# was slow is worse than a page in catalogue order.
products = []
return render(request, "category.html", {"products": products or default_order(slug)})
```
## FastAPI [#fastapi]
Use the async client, and hold it on the app so it is created once and closed on shutdown.
```python title="main.py"
import os
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
BASE = "https://api.thepulseapi.com/api/v1"
ORG = os.environ["PULSE_ORG_ID"]
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.pulse = httpx.AsyncClient(
base_url=BASE,
timeout=5.0,
headers={"x-api-key": os.environ["PULSE_API_KEY"]},
)
yield
await app.state.pulse.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/ranked/{session_id}")
async def ranked(session_id: str, limit: int = 24):
response = await app.state.pulse.post(
"/catalog/ranked",
json={"sessionId": session_id, "organizationId": ORG, "limit": limit},
)
response.raise_for_status()
return response.json()
```
Neither `requests` nor `httpx` has a default timeout in the shape most people expect. A
ranking call that hangs will hold a worker until something else gives up. Five seconds is
generous; the call is usually tens of milliseconds.
# Ruby (/vi/backend/ruby)
```ruby title="lib/pulse.rb"
require "json"
require "net/http"
require "uri"
class Pulse
BASE = URI("https://api.thepulseapi.com/api/v1").freeze
def initialize(api_key:, organization_id:)
@api_key = api_key
@organization_id = organization_id
end
def track(session_id, signals)
post("/track", sessionId: session_id, signals: signals)
end
def ranked_catalog(session_id, limit: 24)
post("/catalog/ranked", sessionId: session_id, limit: limit)
end
def session(session_id)
post("/session", sessionId: session_id)
end
private
def post(path, **body)
# organizationId is required on nearly every endpoint and is read from the body,
# not from the key.
body[:organizationId] = @organization_id
request = Net::HTTP::Post.new(BASE.path + path)
request["Content-Type"] = "application/json"
request["x-api-key"] = @api_key
request.body = JSON.generate(body)
response = Net::HTTP.start(
BASE.host, BASE.port,
use_ssl: true,
open_timeout: 5,
read_timeout: 5,
) { |http| http.request(request) }
raise "PULSE #{path}: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)
end
end
```
## Rails [#rails]
```ruby title="config/initializers/pulse.rb"
PULSE = Pulse.new(
api_key: ENV.fetch("PULSE_API_KEY"),
organization_id: ENV.fetch("PULSE_ORG_ID"),
)
```
```ruby title="app/controllers/catalog_controller.rb"
def show
session_id = cookies[:pulse_session]
@products = if session_id
PULSE.ranked_catalog(session_id, limit: 24)["items"]
end
rescue StandardError
# Personalisation is an enhancement. Catalogue order is a working page.
@products = nil
ensure
@products ||= Product.where(category: params[:slug]).limit(24)
end
```
`open_timeout` bounds the connection and `read_timeout` bounds the wait for a response. Set
only the first and a server that accepts your connection and then stalls holds the request
until something upstream gives up.
# Rust (/vi/backend/rust)
```toml title="Cargo.toml"
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
```rust title="src/pulse.rs"
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
const BASE: &str = "https://api.thepulseapi.com/api/v1";
#[derive(Serialize)]
pub struct Signal {
#[serde(rename = "type")]
pub kind: String,
pub value: String,
}
#[derive(Clone)]
pub struct Pulse {
// reqwest::Client is already an Arc around a connection pool, so cloning it is cheap
// and sharing one across tasks is the intended use. Building one per request is not.
http: reqwest::Client,
org_id: String,
}
impl Pulse {
pub fn new(api_key: &str, org_id: impl Into) -> reqwest::Result {
let mut headers = reqwest::header::HeaderMap::new();
let mut key = reqwest::header::HeaderValue::from_str(api_key).expect("valid api key");
key.set_sensitive(true);
headers.insert("x-api-key", key);
Ok(Self {
http: reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(5))
.build()?,
org_id: org_id.into(),
})
}
async fn post(&self, path: &str, mut body: Value) -> reqwest::Result {
// organizationId is required on nearly every endpoint and comes from the body,
// not from the key.
body["organizationId"] = json!(self.org_id);
self.http
.post(format!("{BASE}{path}"))
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await
}
pub async fn track(&self, session_id: &str, signals: Vec) -> reqwest::Result {
self.post("/track", json!({ "sessionId": session_id, "signals": signals })).await
}
pub async fn ranked_catalog(&self, session_id: &str, limit: u32) -> reqwest::Result {
self.post("/catalog/ranked", json!({ "sessionId": session_id, "limit": limit })).await
}
}
```
```rust
let pulse = Pulse::new(&std::env::var("PULSE_API_KEY")?, std::env::var("PULSE_ORG_ID")?)?;
// Ranking is an enhancement. Fall back to catalogue order rather than failing the response.
let ranked = pulse.ranked_catalog(&session_id, 24).await.unwrap_or_else(|_| json!({ "items": [] }));
```
It keeps the value out of `Debug` output for the header map, which is the kind of log line
that ends up in an error report without anyone deciding to put it there.
# Lịch sử tài liệu (/vi/changelog)
Trang này ghi lại điều gì đã thay đổi trên site và vào lúc nào. Danh sách được đọc từ lịch sử của
chính kho mã khi site được dựng, nên nó không thể lệch khỏi những gì thực sự đã xảy ra và không ai
phải nhớ cập nhật nó.
Quy tắc tương thích của API, thế nào là thay đổi phá vỡ, và chúng tôi có trách nhiệm gì khi điều
đó xảy ra, nằm ở [Phiên bản](/vi/api/versioning). **API được đánh phiên bản theo URL còn các
hướng dẫn thì không**, đó là lựa chọn có chủ đích và được giải thích ở trang đó. Trang này là
lịch sử của chính bộ tài liệu.
## Thay đổi gần đây [#thay-đổi-gần-đây]
Nội dung commit được giữ nguyên tiếng Anh, vì đó là ngôn ngữ của kho mã.
## Xem lịch sử của từng trang [#xem-lịch-sử-của-từng-trang]
Mỗi trang đều hiển thị ngày sửa đổi gần nhất ở cuối trang, kèm liên kết tới toàn bộ lịch sử commit
của riêng tệp đó. Đây là cách nhanh nhất để trả lời câu hỏi "tháng trước trang này có viết khác
không", vốn thường mới là câu hỏi thật sự.
Các trang là tệp MDX trong
[`content/docs`](https://github.com/mypulseapi/handbook/tree/main/content/docs), nên lịch sử chính
là git thông thường: mọi lần sửa, ai sửa, và những gì khác đã thay đổi trong cùng một commit.
# How inference works (/vi/concepts/how-it-works)
Most search and recommendation tools answer, because answering is all they can do. They rank
something for any query, including the queries your catalogue has no data to resolve.
PULSE does two things those do not.
} title="Infers in-session" description="Persona accumulates from behaviour within a single visit, with no identity and no history." />
} title="Knows when it cannot answer" description="If the catalogue cannot resolve a requirement, that is a reportable fact rather than a bad ranking." />
## The loop [#the-loop]
### A signal arrives [#a-signal-arrives]
A category view, a price filter, a device class. See [Signals](/concepts/signals).
### The persona updates [#the-persona-updates]
Each signal moves a set of persona scores. Scores decay, so a session that changes direction
is followed rather than averaged.
### Products are scored against it [#products-are-scored-against-it]
`search` ranks products you pass in. `rankedCatalog` ranks the catalogue already synced to PULSE.
### Exposure is recorded [#exposure-is-recorded]
What was actually shown is what makes ranking quality measurable later. See
[`exposure`](/api/v1/exposure).
## What it is not [#what-it-is-not]
PULSE is not a PIM and it replaces nothing you already run. It reads your catalogue and ranks
against it. If a field is missing everywhere, ranking cannot invent it, and saying so is the
point.
# Sessions (/vi/concepts/sessions)
A session is the unit everything attaches to. It is created by `init()` and held in browser
storage.
```ts
pulse.getSessionId(); // 'sess_...'
pulse.resetSession(); // start clean
await pulse.getSession(); // current personas, server side
```
## Lifetime [#lifetime]
| | Default | |
| --- | --- | --- |
| `sessionTtlMs` | 30 minutes | Inactivity before a new session starts |
## Watching the persona change [#watching-the-persona-change]
```ts
pulse.onPersona((update) => {
console.log(update.primary, update.confidence);
});
```
Useful during integration: if personas never move, the signals are not arriving, and that is
visible in two lines rather than in a ranking that looks vaguely wrong.
# Signals (/vi/concepts/signals)
A signal is one observation about the current session. Send them with
[`track`](/api/v1/track).
```ts
await pulse.track([
{ type: 'category_view', value: 'helmets-safety' },
{ type: 'product_view', value: 'sku-123' },
{ type: 'price_filter', value: '500000-1500000' },
]);
```
## Captured for you [#captured-for-you]
`init()` sends these once per session, unless `autoTrack: false`:
| | |
| --- | --- |
| **Device** | Class and capability, parsed from the user agent |
| **Referrer** | Where the session came from |
| **Time of day** | Local hour bucket |
They cost no network beyond the one call, and they are exported individually so a host app can
re-assert them on a later page load:
```ts
import { captureDeviceSignal, captureReferrerSignal, captureTimeSignal } from '@thepulseapi/sdk';
```
## What is not a signal [#what-is-not-a-signal]
`anonymousId` is an identity label. Nothing in the engine matches on it, so labelling a session
cannot change what is inferred about it. That separation is deliberate: it makes it possible to
attribute a session without contaminating the result.
Age, gender, job title and income are **not** inputs. The engine reads behaviour. If you hold
those attributes, they are ground truth to grade against, not features to feed in.
# What the audit measures (/vi/concepts/what-the-audit-measures)
The audit returns attributes in priority order. The order is the product, and it is not the order
you would get by counting blanks.
## Why not just fix the emptiest column? [#why-not-just-fix-the-emptiest-column]
Because emptiness and usefulness are different quantities, and on a real shelf they invert.
Here are two attributes from the 216-product laptop catalogue we use for our own research:
| Attribute | Missing on | Rank by emptiness | Rank by decision value |
| --- | --- | --- | --- |
| Processor | 162 of 216 | **1st** | **last** |
| Screen size | 80 of 216 | 4th | **1st** |
Processor is missing twice as often and almost nobody is deciding on it: it appears in 2 of the 32
buying requirements we tested. Screen size appears in 13. Fill processor everywhere and you have
done the most visible work on the dashboard and moved the fewest decisions.
A completeness score measures your catalogue against itself. The audit measures it against what
shoppers are trying to decide. Those produce different work orders, and only one of them is
ranked by what it buys.
## What is a bit of decision information? [#what-is-a-bit-of-decision-information]
One bit is one halving of the shelf.
If a shopper faces 216 products and an attribute splits them cleanly in two, that attribute is
worth one bit: it takes 216 to 108. Two bits takes it to 54. The audit reports what each attribute
would be worth **if it were recorded everywhere**, minus what it is worth today, and the
difference is what the repair buys.
This is why the number is not a percentage. A percentage tells you how full a column is. Bits tell
you how much closer a shopper gets to a decision when you fill it.
## Where does the missing value already live? [#where-does-the-missing-value-already-live]
Often in text you already own. Before recommending that anyone type anything, the audit tries to
recover the value from your own titles and descriptions.
On that same laptop shelf, **161 of 216 missing screen sizes were already sitting in the product
titles**. That is a parse, not a procurement project, and it changes what the repair costs enough
to change whether it is worth doing.
The audit reports each attribute's provenance so you can tell the two apart:
| Where the fact is | What the repair actually is |
| --- | --- |
| Already in your text | Extraction. Cheap, and often same-day |
| In a supplier feed | Mapping. A ticket, not a project |
| Nowhere | Acquisition. Someone has to go and find it |
## When is the ranking wrong? [#when-is-the-ranking-wrong]
Three cases, and we would rather you heard them from us.
### The recorded values are not representative [#the-recorded-values-are-not-representative]
Simulating a complete catalogue can only draw on values your shelf already holds. If an attribute
is recorded on twenty products and those twenty are not typical of the range, the simulation is
wrong in a way we can name and cannot yet correct.
### The requirements are not yours [#the-requirements-are-not-yours]
The buying requirements we test against are typed from public category guides and filter rails. If
your shoppers want something those do not cover, the ranking will not know. Sending us your own
search and filter logs is what fixes this, and it is the single highest-value thing you can add.
### The fact exists only in a photograph [#the-fact-exists-only-in-a-photograph]
A dietary claim printed on a pack, a material visible in an image. We can tell you the attribute
is missing from your fields. We cannot yet read it out of your imagery, and we will say so on the
row rather than quietly scoring it as absent.
## What happens next? [#what-happens-next]
} title="Run it" href="/api/v1/audit" description="POST /catalog/audit and /catalog/verify, with the request and response shapes." />
} title="The runtime" href="/concepts/how-it-works" description="The other half: ranking a live session once the catalogue can support it." />
# Alpine (/vi/frameworks/alpine)
Alpine is attribute-driven, so the script-tag build is the natural fit. No bundler, no install.
```html title="index.html"
```
The global `Pulse` **is** the client, so call it directly from Alpine:
```html
```
There is no default export on the global. `Pulse.init` resolves; `Pulse.default.init` does not.
# Angular (/vi/frameworks/angular)
```ts title="src/app/pulse.service.ts"
import { Injectable } from '@angular/core';
import { PulseClient } from '@thepulseapi/sdk';
@Injectable({ providedIn: 'root' })
export class PulseService extends PulseClient {}
```
Initialise from `APP_INITIALIZER`, or from the first component that runs in the browser:
```ts title="src/app/app.config.ts"
import { APP_INITIALIZER } from '@angular/core';
import { PulseService } from './pulse.service';
export const appConfig = {
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [PulseService],
useFactory: (pulse: PulseService) => () =>
pulse.init({ apiKey: 'pk_live_...', organizationId: 'org_...' }),
},
],
};
```
# Astro (/vi/frameworks/astro)
Astro ships whichever island framework you already use, so for islands follow
[React](/frameworks/react) or [Vue](/frameworks/vue).
For a plain Astro page, this is the whole integration:
```astro title="src/layouts/Base.astro"
```
Or skip the install entirely and use the CDN build:
```astro
```
# Overview (/vi/frameworks)
The client is plain TypeScript with no runtime dependencies and no framework coupling. Every
browser API it touches sits behind a `typeof` guard, so importing it during server rendering is
safe. Only `init()` needs a browser.
} title="React and Next.js" href="/frameworks/react" description="Provider and hooks. SSR-safe." />
} title="Vue and Nuxt" href="/frameworks/vue" description="Plugin and provide/inject." />
} title="Angular" href="/frameworks/angular" description="An injectable service." />
} title="Astro" href="/frameworks/astro" description="Islands or a script tag." />
} title="Alpine" href="/frameworks/alpine" description="The global, straight from HTML." />
} title="Laravel" href="/frameworks/laravel" description="Blade partial, keys stay server-side." />
Astro, Alpine and Laravel need no adapter at all. Astro ships whichever island framework you
already use, and the other two are a script tag.
# Laravel (/vi/frameworks/laravel)
Laravel renders on the server, so the browser half is a script tag, usually in a layout.
```php title="config/services.php"
'pulse' => [
'key' => env('PULSE_PUBLISHABLE_KEY'),
'org' => env('PULSE_ORGANIZATION_ID'),
],
```
```blade title="resources/views/partials/pulse.blade.php"
```
```blade title="resources/views/layouts/app.blade.php"
@include('partials.pulse')
```
The publishable `pk_live_` key is designed to be visible in browser code. Anything that
administers your account is not. Use `@json()` rather than string interpolation so a value
containing a quote cannot break out of the script.
## Calling from PHP [#calling-from-php]
To talk to PULSE from PHP rather than the browser, use the [REST API](/api/v1) directly. There
is no PHP package yet.
# React and Next.js (/vi/frameworks/react)
## A provider [#a-provider]
```tsx title="app/pulse-provider.tsx"
'use client';
import { createContext, useContext, useRef } from 'react';
import { PulseClient } from '@thepulseapi/sdk';
const PulseContext = createContext(null);
export const usePulse = () => useContext(PulseContext)!;
export function PulseProvider({ children }: { children: React.ReactNode }) {
const pulse = useRef(null);
pulse.current ??= new PulseClient();
useEffect(() => {
pulse.current!.init({
apiKey: process.env.NEXT_PUBLIC_PULSE_API_KEY!,
organizationId: process.env.NEXT_PUBLIC_PULSE_ORG_ID!,
});
}, []);
return {children};
}
```
The component that calls `init()` must be a **client component**, because the session lives in
the browser. Server Components and SSR are otherwise fine: the import is side-effect free and
every browser API is guarded.
## Using it [#using-it]
```tsx title="components/product-card.tsx"
'use client';
import { usePulse } from '@/app/pulse-provider';
export function ProductCard({ sku }: { sku: string }) {
const pulse = usePulse();
return (
);
}
```
# Vue and Nuxt (/vi/frameworks/vue)
```ts title="plugins/pulse.ts"
import { PulseClient } from '@thepulseapi/sdk';
import type { App } from 'vue';
export default {
install(app: App) {
const pulse = new PulseClient();
// Nuxt renders on the server first; init() is browser-only.
if (!import.meta.env.SSR) {
pulse.init({ apiKey: 'pk_live_...', organizationId: 'org_...' });
}
app.provide('pulse', pulse);
},
};
```
```vue title="components/ProductCard.vue"
```
# Going live (/vi/going-live)
You have run an audit, repaired something, and possibly wired up the runtime. This page is the
end of the path: what to check before it goes in front of shoppers, and what to expect
afterwards.
## What should I check before shipping? [#what-should-i-check-before-shipping]
### Keys are the right kind [#keys-are-the-right-kind]
A publishable key (`pk_live_...`) is designed to be visible in page source. A secret key is not,
and belongs only in a server guide. If a secret key reached a theme file or a client bundle,
revoke it now rather than after the deploy.
### A held-back group exists [#a-held-back-group-exists]
Decide what fraction of traffic never sees PULSE ranking, and keep it. Without a control arm you
cannot tell a good quarter from a good ranker, and you will not be able to answer the first
question your finance team asks.
### Your own order is the fallback [#your-own-order-is-the-fallback]
If the engine is slow or unavailable, the page should render your existing order rather than an
empty shelf. Check it by pointing `endpoint` at a port nothing is listening on and loading the
page.
### `init()` failure is visible to you [#init-failure-is-visible-to-you]
`init()` sends the automatic signals exactly once and swallows its own error. A session whose
`init()` failed stays thin for its whole life, and nothing in the page tells you. Log the
failure, or call
[`captureAutoSignals()`](/sdk#beyond-the-client) again on a later page load.
### Exposure is recorded [#exposure-is-recorded]
If you rank, call `exposure()` with what was actually rendered. A ranking you cannot tie to what
the shopper saw cannot be evaluated later, and the buffered call costs almost nothing.
## What should I watch in the first week? [#what-should-i-watch-in-the-first-week]
| Watch | Why it matters |
| --- | --- |
| Sessions with zero signals | Almost always `init()` failing silently, or a key scoped to the wrong origin |
| Abstention rate | The engine declining to rank is working as designed. A sudden move in it usually means the catalogue changed, not the engine |
| Audit readiness, re-run | Catalogues decay. New products arrive unrecorded, and a shelf that scored well last quarter quietly stops |
| Fallback rate | How often your own order was served instead. Should be near zero and is not always |
## What will you not claim for me? [#what-will-you-not-claim-for-me]
Worth reading before you repeat anything from this site internally.
We have run no live revenue experiment. We will not quote a conversion lift, a return-rate
reduction or an ROI multiple, and any figure you see elsewhere attached to those words did not
come from us.
What we can show is a property of your catalogue: which requirements it can and cannot resolve,
before and after a repair. That is measured, it is reproducible, and it is not the same thing as
a sale.
Three more limits, stated plainly:
- **A fact that exists only inside a photograph is one we can tell you is missing and cannot yet
recover for you.** Extracting attributes from product imagery is research we are doing, not a
feature we ship.
- **Our simulation of a complete catalogue can only use values your shelf already records.**
Where an attribute's recorded values do not represent its true range, the simulation is wrong
in a way we can name and cannot yet correct.
- **We have no customers to name.** When that changes we will say so with their permission.
## Where does this go next? [#where-does-this-go-next]
} title="API reference" href="/api/v1" description="Every endpoint a publishable key can call, with the error shapes." />
} title="SDK reference" href="/sdk" description="Every method on the client, and the functions beside it." />
} title="How inference works" href="/concepts/how-it-works" description="What the engine measures, and when it is wrong." />
# Giới thiệu (/vi)
Phần lớn công cụ ở lĩnh vực này xếp hạng những gì người mua nhìn thấy. PULSE hỏi câu nằm bên
dưới câu đó: danh mục của bạn có trả lời được điều họ đến để tìm hiểu hay không? Bạn gửi một tệp
xuất ngành hàng, nhận lại các thuộc tính đáng ghi theo thứ tự ưu tiên, rồi đo lại sau khi đội của
bạn đã điền xong.
Ban đầu không có gì được cài lên website của bạn. Câu trả lời hữu ích đầu tiên đến từ một tệp.
PULSE do **Resolve Labs** làm ra, một đội nhỏ theo đuổi một câu hỏi: thông tin sản phẩm nào đáng
bỏ tiền ra sửa. Trang giới thiệu là [thepulseapi.com](https://thepulseapi.com/vi). Trang này
dành cho những người phải tích hợp nó.
## Sản phẩm này thực sự giải quyết vấn đề gì? [#sản-phẩm-này-thực-sự-giải-quyết-vấn-đề-gì]
Một người mua rời đi mà không mua gì. Hoặc là họ không tìm thấy sản phẩm, hoặc là danh mục của bạn
không giúp họ phân biệt được các sản phẩm với nhau.
Vế thứ hai lớn hơn và gần như không ai đo nó. Trên một kệ 216 mẫu laptop mà chúng tôi đã đo,
**94 sản phẩm vẫn không thể phân biệt được** sau mọi câu hỏi mà người mua có thể trả lời, bởi vì
những thuộc tính giúp tách chúng ra không được ghi ở đâu cả. Không mô hình xếp hạng nào sửa được
điều đó. Chỉ có việc ghi lại thuộc tính mới sửa được.
Nên PULSE bắt đầu từ danh mục chứ không phải từ phiên truy cập:
| Câu hỏi | PULSE trả lời bằng |
| --- | --- |
| **Trường dữ liệu còn thiếu nào tốn kém nhất?** | Một bảng xếp hạng theo số người mua cần nó, không phải theo mức độ trống của cột |
| **Dữ kiện đó vốn đang nằm ở đâu?** | Thường là trong chính tiêu đề và mô tả của bạn, lấy lại được trước khi ai đó phải gõ gì |
| **Bản sửa có tác dụng không?** | Cùng một phép đo trên tệp xuất đã sửa, không cần lưu lượng truy cập |
## Nên bắt đầu từ đâu? [#nên-bắt-đầu-từ-đâu]
}
title="Bạn đến từ thepulseapi.com"
href="/vi/quickstart"
description="Bạn đã đọc bản kiểm tra làm gì. Đây là cách tự chạy một bản, trong khoảng mười dòng."
/>
}
title="Bạn vào thẳng trang này"
href="/vi/concepts/what-the-audit-measures"
description="Bắt đầu từ việc bộ máy đo cái gì và vì sao con số đó có ý nghĩa."
/>
## Toàn bộ hành trình trông ra sao? [#toàn-bộ-hành-trình-trông-ra-sao]
Sáu bước, theo đúng thứ tự bạn sẽ gặp. Mỗi bước tự nó đã hữu ích, và bạn có thể dừng lại sau bất
kỳ bước nào.
### Đo danh mục [#đo-danh-mục]
`POST /catalog/audit` nhận một tệp xuất ngành hàng và trả về các thuộc tính đáng ghi, đã xếp hạng,
kèm danh sách sản phẩm bị ảnh hưởng. Không lưu trạng thái, và không lưu gì trừ khi bạn yêu cầu.
[Chạy bản kiểm tra đầu tiên](/vi/quickstart)
### Hiểu kết quả trả về [#hiểu-kết-quả-trả-về]
Thứ hạng được đo bằng bit thông tin quyết định, không phải đoán từ lượt nhấp, và cột trống nhất
thường là cột không đáng sửa nhất.
[Bản kiểm tra đo gì](/vi/concepts/what-the-audit-measures)
### Sửa, rồi đo lại [#sửa-rồi-đo-lại]
Đội của bạn điền thuộc tính đứng đầu bảng. `POST /catalog/verify` chạy lại đúng phép đo đó trên
tệp xuất đã sửa và báo cáo điều gì đã thay đổi, theo từng thuộc tính. Nó đọc danh mục chứ không
đọc lưu lượng, nên không phải chờ gì cả.
[Tài liệu endpoint](/vi/api/v1/audit)
### Đưa vào hệ thống của bạn [#đưa-vào-hệ-thống-của-bạn]
Chỉ cần khi bạn muốn dùng phần thời gian thực. Có hướng dẫn cho React, Vue, Angular, Astro, Alpine
và Laravel; hướng dẫn gian hàng cho Shopify, WordPress và Magento; hướng dẫn máy chủ cho tám ngôn
ngữ.
[Framework](/vi/frameworks) · [Gian hàng](/vi/platforms) · [Máy chủ](/vi/backend)
### Xếp hạng phiên truy cập, nếu bạn muốn [#xếp-hạng-phiên-truy-cập-nếu-bạn-muốn]
Phần tuỳ chọn, và là phần duy nhất chạm tới gian hàng của bạn. SDK gửi tín hiệu, bộ máy xếp hạng
danh mục theo những gì phiên truy cập hàm ý, và mọi điểm số đều kèm diễn giải theo từng yếu tố.
[Tài liệu SDK](/vi/sdk) · [Tài liệu API](/vi/api/v1)
### Đưa lên production [#đưa-lên-production]
Cần kiểm tra gì trước khi phát hành, cần theo dõi gì sau đó, và những điều chúng tôi sẽ không
tuyên bố.
[Đưa lên production](/vi/going-live)
## Cài đặt [#cài-đặt]
Hoặc nhúng thẳng vào trang, đây là cách các bản tích hợp gian hàng đang dùng:
```html title="index.html"
```
Thẻ script này expose client ra biến toàn cục `Pulse`, nên gọi `Pulse.init(...)` là chạy được ngay.
Tín hiệu và một mã phiên. Không có tên, không có email, không có dữ liệu thanh toán. Bản kiểm
tra thì không nhìn thấy người mua nào cả: nó đọc dữ liệu sản phẩm.
## Cần biết gì trước khi tin vào các con số? [#cần-biết-gì-trước-khi-tin-vào-các-con-số]
} title="3,2 KB sau nén" description="Bản dùng cho thẻ script, không phụ thuộc thư viện nào khi chạy." />
} title="Không cần lịch sử" description="Bản kiểm tra chỉ cần một tệp xuất. Phần thời gian thực xếp hạng ngay từ lượt truy cập đầu tiên." />
} title="Giấy phép MIT" description="Mã nguồn client là mở. Hãy đọc từng dòng trước khi đưa lên production." />
} title="Không hứa hẹn tăng trưởng" description="Chúng tôi chưa chạy thử nghiệm doanh thu thực tế nào, nên không đưa ra con số chuyển đổi." />
# MCP cho tác tử (/vi/mcp)
Mọi trang trên site này đều có thể truy cập bằng tác tử qua Model Context Protocol. Một trợ lý đã
kết nối sẽ tìm trong tài liệu và trích dẫn, thay vì nhớ nhầm một endpoint không tồn tại.
## Kết nối thế nào? [#kết-nối-thế-nào]
Hoặc trỏ thẳng client của bạn vào đó, không cần cài:
```json title="claude_desktop_config.json"
{
"mcpServers": {
"pulse-docs": {
"command": "npx",
"args": ["-y", "@thepulseapi/docs-mcp"]
}
}
}
```
Cùng khối cấu hình đó dùng được cho Claude Code (`.mcp.json`), Cursor và mọi thứ nói MCP qua stdio.
Site này là bản xuất tĩnh nên ở đây không có máy chủ nào để dựng một endpoint HTTP. Gói này chạy
ở nơi tác tử của bạn chạy và tải chỉ mục tài liệu đã xuất bản, nghĩa là không có gì về câu lệnh
hay mã nguồn của bạn đi tới chúng tôi.
## Có những công cụ nào? [#có-những-công-cụ-nào]
| Công cụ | Làm gì |
| --- | --- |
| `search_docs` | Tìm toàn văn trên mọi trang. Trả về đúng mục khớp, URL và điểm số |
| `get_page` | Một trang dạng Markdown, theo đường dẫn, ví dụ `/quickstart` hay `/api/v1/audit` |
| `list_pages` | Mọi trang kèm tiêu đề, để định hướng trước khi tìm |
Cả ba đều nhận `lang` là `en` hoặc `vi`.
## Vì sao tìm theo từ khoá chứ không phải theo ngữ nghĩa? [#vì-sao-tìm-theo-từ-khoá-chứ-không-phải-theo-ngữ-nghĩa]
Website nhúng câu hỏi của bạn bằng một mô hình mà nó đã tải sẵn. Bắt một tác tử tải mô hình chỉ để
đặt câu hỏi là đánh đổi không đáng, vì bản thân tác tử đã là một mô hình ngôn ngữ: nó đọc ba mục
ứng viên và tự quyết định tốt hơn nhiều so với một phép cosine.
Thêm nữa, một tác tử hỏi về `/catalog/audit` muốn đúng trang chứa chuỗi đó, và tìm theo từ khoá
chính xác ở điểm này trong khi embedding thì làm nhoè đi.
## Muốn trỏ sang nơi khác thì sao? [#muốn-trỏ-sang-nơi-khác-thì-sao]
Đặt `PULSE_DOCS_ORIGIN` để đọc từ một origin khác, chủ yếu hữu ích khi bạn đang sửa chính tài liệu
này:
```bash
PULSE_DOCS_ORIGIN=http://localhost:3000 npx @thepulseapi/docs-mcp
```
## Đọc thẳng Markdown được không? [#đọc-thẳng-markdown-được-không]
Được, và không cần MCP.
| | |
| --- | --- |
| [`/llms.txt`](/llms.txt) | Mọi trang, có tiêu đề và liên kết, trong một tệp |
| [`/llms-full.txt`](/llms-full.txt) | Toàn bộ tài liệu gộp thành một tài liệu Markdown |
| `/llms.mdx/docs//content.md` | Một trang bất kỳ dạng Markdown |
| [`/search-index.json`](/search-index.json) | Kho văn bản đã chia khối kèm vector |
Mỗi trang cũng có nút **Sao chép Markdown**, là cách nhanh nhất khi bạn chỉ muốn dán một trang vào
cuộc hội thoại.
# Overview (/vi/platforms)
Not in the Shopify App Store, not on the WordPress plugin directory, not in the Magento
Marketplace. Anyone telling you otherwise is describing a plan. What exists today is the
script tag, and it works on all three.
That is less of a limitation than it sounds. PULSE needs three things from a storefront: the
script, an `init` call with your keys, and signals describing what the shopper is looking at.
Every platform here can supply all three from its own theme templates, which is also the only
place that knows what page the shopper is on.
} title="Shopify" href="/platforms/shopify" description="theme.liquid, with signals from Liquid objects." />
} title="WordPress and WooCommerce" href="/platforms/wordpress" description="wp_enqueue_script, with signals from the WooCommerce conditionals." />
} title="Magento 2" href="/platforms/magento" description="Layout XML and a template block." />
## What an app would add [#what-an-app-would-add]
Worth being precise about what you give up by pasting a script instead:
| | Script tag, today | An app or plugin |
| --- | --- | --- |
| Install | Paste into a theme file | One click, no theme edit |
| Keys | Typed into the theme | Entered in an admin screen |
| Survives a theme change | No | Yes |
| Catalogue sync | You send products per request | Pushed and kept current |
| Signals | You write them per template | Mapped for you |
The first four rows are convenience. The fifth is where the difference actually lives, and it is
also why a plugin is not just packaging: a good one knows what a "collection page" means on that
platform and sends the right signal without being told.
## Other platforms [#other-platforms]
Anything that lets you add a script to a page can run the browser SDK, and anything that can
make an HTTPS request can call the API directly. If your platform is not listed, the
[Alpine guide](/frameworks/alpine) is the closest shape: a script tag and HTML attributes, no
build step. For server-side work, see [Backend](/backend).
# Magento 2 (/vi/platforms/magento)
No PULSE extension exists. This is a small module you add yourself, in the shape Magento
expects.
Magento does not take a pasted script tag. Markup belongs to a template, templates are attached
by layout XML, and both live in a module. That is three files before anything runs, which is the
platform rather than PULSE.
## The module [#the-module]
```php title="app/code/Pulse/Tracking/registration.php"
```
## Loading the SDK [#loading-the-sdk]
`default.xml` applies to every page, which is what you want for `init`.
```xml title="app/code/Pulse/Tracking/view/frontend/layout/default.xml"
```
`src_type="url"` is load bearing. Without it Magento treats the path as a local static asset and
tries to resolve it inside `pub/static`, where it does not exist.
```php title="app/code/Pulse/Tracking/view/frontend/templates/init.phtml"
```
## Page signals [#page-signals]
Category and product pages each get their own layout handle, so each can attach its own block
without any conditional logic.
```xml title="view/frontend/layout/catalog_category_view.xml"
```
The equivalent file for products is `catalog_product_view.xml`. Each template sends one `track`
call built from the registry object its block resolves.
Hard-coding them in a template means a deployment to change a key. A `system.xml` field read
through `ScopeConfigInterface` puts them in the admin, which is also where a store operator
expects to find them.
# Shopify (/vi/platforms/shopify)
This is a theme edit. It does not survive switching themes, and it puts your keys in a theme
file that anyone with theme access can read. Publishable keys are designed to be visible in
page source, so that is a housekeeping problem rather than a security one.
## 1. Add the script [#1-add-the-script]
**Online Store → Themes → Edit code → `layout/theme.liquid`**, just before ``:
```liquid title="layout/theme.liquid"
```
The script tag exposes the client as the global `Pulse`. `async` means it may not have loaded
when inline script runs, which is why `init` goes inside a `load` listener rather than being
called immediately.
## 2. Send signals from the templates [#2-send-signals-from-the-templates]
Liquid already knows what the shopper is looking at. Each template sends what it knows.
```liquid title="sections/main-collection-banner.liquid"
```
```liquid title="sections/main-product.liquid"
```
`{{ product.vendor | json }}` escapes the value properly. Wrapping it in your own quotes breaks
on the first vendor with an apostrophe in the name, and does it only for that vendor, which is
the kind of bug that reaches production.
## 3. Record outcomes [#3-record-outcomes]
Shopify fires its own events. Forward the ones that mean something.
```liquid title="layout/theme.liquid"
```
The event name varies by theme: Dawn and its descendants publish `cart:item-added`, older themes
publish their own. Check what your theme dispatches before trusting this one.
## What is not covered [#what-is-not-covered]
Checkout. Shopify does not let a theme script run on checkout pages outside Plus, so purchase
outcomes have to come from your server via a webhook, not from the browser. See
[Backend](/backend) for the call to make when the webhook arrives.
# WordPress and WooCommerce (/vi/platforms/wordpress)
There is no PULSE plugin to install. What follows is a short one you write yourself, which is
perhaps twenty lines and does not need to be anything more.
## Write it as a plugin, not a theme edit [#write-it-as-a-plugin-not-a-theme-edit]
Put it in `wp-content/plugins/pulse-tracking/pulse-tracking.php`. A theme edit disappears the
next time the theme updates; a plugin does not, and a child theme is more work than this.
```php title="wp-content/plugins/pulse-tracking/pulse-tracking.php"
get_id(), 'product_cat');
$signals = [['type' => 'product_view', 'value' => (string) $product->get_id()]];
if (is_array($terms) && $terms) {
$signals[] = ['type' => 'category_view', 'value' => $terms[0]->slug];
}
return $signals;
}
if (is_product_category()) {
$term = get_queried_object();
return [['type' => 'category_view', 'value' => $term->slug]];
}
if (is_search()) {
return [['type' => 'search', 'value' => get_search_query()]];
}
return [];
}
```
It is WordPress's `json_encode` with the flags already set for embedding in HTML. Building the
JavaScript by string concatenation breaks on the first product category with a quote in its
name, and only for that category.
## Outcomes [#outcomes]
```php
add_action('woocommerce_add_to_cart', function ($cart_item_key, $product_id) {
// Fires server-side, so this records the outcome where it actually happened
// rather than guessing from a click in the page.
wp_add_inline_script('pulse-sdk', sprintf(
'Pulse.event("add_to_cart", { productId: %s });',
wp_json_encode((string) $product_id)
), 'after');
}, 10, 2);
```
For a purchase, `woocommerce_thankyou` runs on the order received page and has the order, which
is the right place to record revenue. Orders that complete off-site need the
[server-side call](/backend/php) instead.
# Quick start (/vi/quickstart)
### Get your keys [#get-your-keys]
Sign in to the [dashboard](https://app.thepulseapi.com) and open **Integration → API keys**.
Create a key, name it for where it will run, and copy it immediately.
The full key is returned only at creation. Only its hash is stored. If you lose it, revoke it
and create another.
Your **organization id** is on the same page. You need both.
### Install [#install]
### Start a session [#start-a-session]
```ts title="pulse.ts"
import { PulseClient } from '@thepulseapi/sdk';
export const pulse = new PulseClient();
await pulse.init({
apiKey: 'pk_live_...',
organizationId: 'org_...',
});
```
`init()` also captures device, referrer and time of day once per session, unless you pass
`autoTrack: false`.
### Send what the shopper did [#send-what-the-shopper-did]
```ts
await pulse.track([
{ type: 'category_view', value: 'helmets-safety' },
{ type: 'price_filter', value: '500000-1500000' },
]);
```
### Rank your catalogue [#rank-your-catalogue]
```ts
const { results } = await pulse.search(products);
results[0].scoreBreakdown; // why this one came first
```
Every result carries the signals that matched it, so you can show a shopper why, or show
yourself why when it looks wrong.
## Without a bundler [#without-a-bundler]
```html title="index.html"
```
# SDK reference (/vi/sdk)
## Session [#session]
| Method | Returns | |
| --- | --- | --- |
| `init(config)` | `TrackResponse \| null` | Start a session and send the automatic signals once |
| `getSession()` | `SessionResponse` | Current session and inferred personas |
| `getSessionId()` | `string` | The current session id |
| `resetSession()` | `void` | Clear it and start fresh |
| `onPersona(cb)` | `void` | Subscribe to persona updates |
## Signals and ranking [#signals-and-ranking]
| Method | Returns | |
| --- | --- | --- |
| `track(signals)` | `TrackResponse` | Send shopper signals |
| `search(products, options?)` | `SearchResponse` | Rank products you pass in |
| `rankedCatalog(options?)` | `RankedCatalogResponse` | Rank the catalogue already synced to PULSE |
| `event(type, data?)` | `EventResponse` | Record a named event |
`search` and `rankedCatalog` are not interchangeable. `search` ranks what you hand it;
`rankedCatalog` ranks what PULSE already holds.
## Exposure [#exposure]
| Method | | |
| --- | --- | --- |
| `exposure(decisionId, items)` | `void` | Record what was actually rendered, buffered |
| `flushExposures()` | `Promise` | Send buffered exposures now |
## Config [#config]
## Beyond the client [#beyond-the-client]
`PulseClient` is most of the package and not all of it. These are exported too, for the cases
where you need a signal without a client, or need to look at what the SDK cached.
### Signal capture [#signal-capture]
`init()` calls `captureAutoSignals()` for you when `autoTrack` is on, so you only need these
directly when you want a signal at a different moment, or want one the automatic set leaves out.
| Export | Returns | |
| --- | --- | --- |
| `captureAutoSignals()` | `Signal[]` | Device, referrer and time in one call. What `init()` sends |
| `captureDeviceSignal()` | `Signal \| null` | Device class from the user agent |
| `captureReferrerSignal()` | `Signal \| null` | Where the visit came from |
| `captureTimeSignal()` | `Signal` | Time of day. Never null, there is always a clock |
| `captureEnhancedReferrerSignals()` | `Signal[]` | Campaign and channel detail beyond the plain referrer |
| `captureDeviceInfoAsync(): Promise` | `DeviceInfo` | Fuller device detail, including client hints |
| `captureGeoInfo(): Promise` | `GeoInfo \| null` | Coarse location from the IP |
| `geoToSignals(geo)` | `Signal[]` | Turn a `GeoInfo` into signals |
| `captureWeatherInfo(lat, lon, apiKey?): Promise` | `WeatherInfo \| null` | Conditions at a point |
| `weatherToSignals(weather)` | `Signal[]` | Turn a `WeatherInfo` into signals |
| `captureCulturalCalendar(countryCode)` | `Signal[]` | Seasonal and cultural events active today |
| `parseUserAgent(ua)` | `DeviceInfo` | Parse a user agent string you already have |
It calls OpenWeatherMap and returns `null` without an `apiKey`, so nothing breaks if you leave
it out. The free tier is 1,000 calls a day, which is why the result is cached per session
rather than fetched per page.
`captureAutoSignals()` is exported for one specific failure. `init()` sends the automatic
signals exactly once and swallows its own error, so a session whose `init()` failed never
re-sends them and stays thin for its whole life. These signals cost no network of their own, so
calling this again on a later page load is cheaper than the alternative.
### Session context [#session-context]
Device, location and weather are properties of the session rather than of the page: they cannot
change between one page and the next. The SDK captures them once, keys the cache by session id so
it cannot outlive the visitor it describes, and reuses it. You rarely need to touch this.
| Export | Returns | |
| --- | --- | --- |
| `readSessionContext(sessionId)` | `SessionContext \| null` | The cache, or null if it belongs to another session |
| `writeSessionContext(sessionId, patch)` | `void` | Merge fields into it |
| `isWeatherFresh(ctx, now?)` | `boolean` | Whether the weather reading is still inside its TTL |
| `clearSessionContext()` | `void` | Drop the cache |
| `WEATHER_TTL_MS` | `number` | 10 minutes |
Weather is the only field here with a TTL. Everything else is immutable for the session, but a
30-minute session can outlast a rain shower, and a `weather:rain` signal driving a
recommendation after the sun came out is the engine being confidently wrong.