REST API (Pulse v1)
Read a visitor profile or list the members of a custom segment from your backend. Use a site-scoped API key and the examples below to make your first request.
Base URL & auth
All endpoints live under /api/pulse/v1/ and authenticate with a bearer key. Create keys in Admin → Pulse; the pls_live_… plaintext is shown once. Keep the key on your server; do not put it in browser code. Each key carries scopes that determine what the endpoint returns.
| Scope | Type | Description |
|---|---|---|
| profiles:export | scope | Export profiles matching a predicate. |
| profiles:read | scope | Include raw identifiers in bulk profile exports. Separate from the single-profile scopes below. |
| webhook:write | scope | Manage site webhook subscriptions and delivery recovery. Creating subscriptions also requires profile:read. |
| profile:read | scope | Read a profile snapshot: identity, segments, activity – no plaintext PII. |
| profile:read:pii | scope | Additionally return plaintext email / phone / name on profile reads. |
| segment:read | scope | List the profile IDs in a segment (cursor-paginated). |
Get a profile
Read one profile by exactly one identifier. Returns a ProfileSnapshot (identity, segments, activity, meta). Requires profile:read. The segments list contains custom segment memberships. With profile:read:pii, the response also includes identity.pii and plaintext identifiers. Without it those fields are omitted; the supplied lookup value is still echoed in queried_by.
| Parameter | Type | Description |
|---|---|---|
| profile_id optional | string | Look up by AXO profile ID. |
| visitor_id optional | string | Look up by first-party visitor ID. |
| email optional | string | Look up by plaintext email (requires profile:read:pii to return PII). |
| external_id optional | string | Look up by your own external identifier. |
| email_sha256 / phone_sha256 optional | string | Look up by a hashed identifier (privacy-preserving). |
curl -sS "https://axoapp.ai/api/pulse/v1/profile?email=customer@example.com" \
-H "Authorization: Bearer pls_live_YOUR_KEY"List segment members
Requires segment:read and the ID of a custom segment from Segments. Built-in behavioral labels such as deliberator are not custom segment IDs. Each member includes a profile_id and the time it was added.
Set limit to 1–1,000 (default 100). Pass next_cursor unchanged as the URL-encoded cursor query parameter to read the next page. Results are newest first. Stop when next_cursor is null.
curl -sS "https://axoapp.ai/api/pulse/v1/segment/seg_123/members?limit=100" \
-H "Authorization: Bearer pls_live_YOUR_KEY"Errors and limits
Except for delivery recovery, errors use { "error": { "code": "…", "message": "…" } }, with optional details. Read the HTTP status and error.code before retrying. Rate limits apply per key; monthly usage limits apply per site.
| Status | Type | Description |
|---|---|---|
| 400 | validation_error | Missing or conflicting query (supply exactly one identifier). |
| 401 | unauthorized | Missing or invalid bearer key. |
| 402 | plan_limit | Monthly Pulse usage reached the plan hard limit. Check Admin → Pulse and the X-Pulse-Quota-Limit / X-Pulse-Quota-Used headers. |
| 403 | forbidden | The key lacks profile:read or segment:read, as required by the endpoint. |
| 404 | not_found | No profile/segment matches the query. |
| 429 | rate_limited | Rate limit exceeded (60 requests/minute, burst 120). Wait for the Retry-After seconds before retrying. |
Export profiles matching a rule
Use profiles:export to export visitors who match a custom segment predicate. This example selects visitors with at least two page views and no conversion in the last seven days. The export response differs from the single-profile snapshot above.
The predicate is required. Combine rules with all_of, any_of, and not. Use { "kind": "segment_member", "segment_id": "seg_123" } for an existing custom segment or { "kind": "everyone" } for all profiles. The OpenAPI SegmentPredicate schema includes the full rule vocabulary and is generated from the request validator. Site evaluation-window settings apply when a rule omits within_seconds.
| Parameter | Type | Description |
|---|---|---|
| predicate required | SegmentPredicate | The rule tree to evaluate. |
| fields optional | string[] | segment, attributes, lifetime_signals, identifiers, identifiers_hashed. Defaults to all five. profile_id, first_seen, and last_seen are always returned. |
| limit optional | integer | 1–1,000; default 100. |
| cursor optional | string | The previous next_cursor, a profile ID. Omit for the first page. |
curl --fail-with-body "https://axoapp.ai/api/pulse/v1/profiles/query" \
-H "Authorization: Bearer $PULSE_KEY" \
-H "Content-Type: application/json" \
--data '{
"predicate": {
"all_of": [
{
"kind": "event_occurred",
"event_type": "pageview",
"count_op": "gte",
"count": 2,
"within_seconds": 604800
},
{
"not": {
"kind": "event_occurred",
"event_type": "conversion",
"within_seconds": 604800
}
}
]
},
"fields": [
"segment",
"identifiers_hashed"
],
"limit": 100
}'Identifier access: raw identifiers requires profiles:read (plural). Without it the array is empty. This differs from the single-profile endpoint’s profile:read:pii scope. Request only the fields you need; attributes contains stored CRM attributes and is not a guarantee of anonymized data.
Results sort by profile ID. Pass next_cursor unchanged until it is null; a full final page may require one extra request. Membership is evaluated again on each request. An unknown cursor restarts at the beginning, so deduplicate by profile_id when a long export spans membership changes. Exported rows count toward export usage.
Subscribe to profile changes
- Create a server endpoint that accepts POST requests and verifies the raw request body.
- Create a Pulse key with
webhook:writeandprofile:read. Addprofile:read:piionly when you need plaintext identity data. - Send the request below. Store the returned
secretsecurely; it is shown only once. - Accept signed
profile.changedevents, persist their IDs, and return a 2xx response promptly.
The destination must resolve to a public HTTP(S) address without credentials in its URL. Use HTTPS. profile.changed is currently the only event. Omitting events selects it; include_pii defaults to false. Subscriptions belong to the key’s site.
curl --fail-with-body "https://axoapp.ai/api/pulse/v1/webhooks" \
-H "Authorization: Bearer $PULSE_KEY" \
-H "Content-Type: application/json" \
--data '{
"url": "https://example.com/hooks/axo",
"events": [
"profile.changed"
],
"include_pii": false
}'All shortened paths in this section are relative to https://axoapp.ai/api/pulse/v1.
GET /webhooks returns { "webhooks": [...] }. Each row contains id, site_id, url, events, include_pii, active, created_by_key_id, last_delivery_at, failure_count, created_at, and updated_at. It never returns the secret.
DELETE /webhooks/{id} returns { "ok": true }, or 404 if the subscription does not exist on the key’s site. Both operations require webhook:write. To rotate a subscription secret, create a replacement, configure its secret at your receiver, then delete the old subscription. Deduplicate overlapping notifications.
Verify and process a delivery
Headers are X-Pulse-Signature (hex HMAC-SHA256 of the raw body), X-Pulse-Event: profile.changed, and X-Pulse-Delivery-Id. The body is { "id": "…", "event": "profile.changed", "timestamp": "…", "data": { … } }. data has the same ProfileSnapshot shape as a profile read; id matches the delivery header.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody must be the original request bytes, before JSON parsing.
export function verifyPulse(rawBody, signature, secret) {
if (typeof signature !== 'string' || !/^[a-f0-9]{64}$/i.test(signature)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}
// In your POST handler:
// 1. Read raw bytes and X-Pulse-Signature. Reject an invalid signature.
// 2. Parse JSON; check event === 'profile.changed' and required fields.
// 3. Atomically persist/enqueue the event using event.id as a unique key.
// 4. Return 2xx after durable acceptance (also for an already accepted ID).
// 5. Process the queued snapshot outside the request.The signed timestamp is the enqueue time, not a fresh timestamp for every attempt. Deduplicate by delivery ID; a short timestamp rejection window can reject legitimate retries. After durable acceptance, retain your accepted snapshot and ignore further attempts with the same ID; use a fresh profile read when you need the latest state. A retry keeps its ID but rebuilds the latest privacy-filtered snapshot, so its body can change. Treat this as a latest-state notification, not an immutable history of every profile edit.
A 2xx response marks success. Network failures, HTTP 408, 429, and 5xx responses retry up to six total attempts. Backoff starts at 30 seconds and doubles; worker scheduling can add delay. Other non-2xx responses fail immediately. The receiver has an eight-second request timeout. Revoked keys, removed scopes, inactive subscriptions, and unavailable profile authorization can cancel delivery. There is no exactly-once guarantee.
Inspect and recover deliveries
GET /webhooks/deliveries returns { "deliveries": [...] }, the newest 100 rows across subscriptions on the key’s site, with no pagination. Both delivery operations require webhook:write. Add ?terminal=true for failed and cancelled rows. Each row has id, webhook_id, profile_id, status, attempts, available_at, response_code, error, created_at, and updated_at. Status is pending, processing, success, failed, or cancelled. Completed rows are retained for 30 days.
Fix the receiver or authorization first. Then POST { "id": "delivery_id" } to /webhooks/deliveries to reset a failed/cancelled delivery. Replay preserves its ID and rebuilds the latest snapshot. A receiver that already durably accepted that ID should ignore the replay; use a fresh profile read to reconcile it. A 202 response contains { "replayed": true }. A 404 response contains { "replayed": false } when the ID is absent or not replayable.
To reconcile a profile’s latest state, POST { "profile_id": "prf_123" } without id. This also needs profile:read. Responses are 202 with { "queued": true } or 404 with { "queued": false }. A true result confirms that the profile exists and enqueue was attempted; inspect active subscriptions and the delivery log to verify delivery.
Error exception: the deliveries endpoint returns { "error": "message" }, with a string rather than the structured error used elsewhere. A missing webhook:write scope returns 401 here. Invalid replay input returns 400; reconciliation without profile:read returns 403.
Build clients that tolerate change
Use the /v1/ paths, accept additional response fields, and handle nullable values. Keep unknown event types out of business processing. Validate the fields your application needs rather than rejecting every unrecognized field. Pin generated clients to a reviewed copy of the OpenAPI document and compare it before updating.
These contracts describe the current implementation. AXO has not published a fixed deprecation notice period. If your rollout requires one, agree it with AXO before depending on that guarantee.