Org Export API
Pull conversations, usage, and session metadata across every app in your organization with a single org API key (chipp_org_*) -- built for Snowflake warehouse sync, transcript export, and scheduled scripts
The Org Export API is a read-only REST API scoped to your entire organization rather than a single app. Use it to pull all your chat transcripts, usage data, and session metadata into a warehouse (Snowflake, BigQuery, Postgres), run attribution analysis on a schedule, or keep your warehouse’s copy in sync with deletions via the /tombstones feed.
The Builder API (chipp_ keys) covers one app at a time. The Org Export API (chipp_org_* keys) covers all apps in your org with one key and one pull loop.
Authentication
Mint a key from the Chipp dashboard under Settings (org-level) or ask the Alchemist:
“Create an org read API key with the transcripts and usage scopes.”
Send the key as a Bearer token on every request:
Authorization: Bearer chipp_org_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxBase URL: https://dino-mullet.chipp.ai/api/v1/org
Rate limit: 120 requests/minute per key. A 429 response includes Retry-After.
Scopes
Each key carries one or more read scopes. Requests outside a key’s scopes return 403:
| Scope | Grants |
|---|---|
apps:read | List the org’s apps |
transcripts:read | Read chat sessions and messages |
usage:read | Read token usage and cost rows |
tombstones:read | Read the deletion-tombstone feed (which apps/sessions/consumers were deleted) |
Pagination
List endpoints (/sessions, /usage, /tombstones) are cursor-paginated, newest first:
?limit=(default 50, max 100)- Follow
pagination.next_cursoruntilpagination.has_moreisfalse
"pagination": { "next_cursor": "eyJ...", "has_more": true, "limit": 50 }Incremental sync checkpoint: last_activity_at / updated_after
/sessions returns exactly one authoritative, always non-null watermark
field: last_activity_at. Its value is the server’s effective incremental
expression,
GREATEST(COALESCE(last_message_at, started_at), <internal touch clock>)
— the same expression the endpoint sorts and cursor-paginates by. The
COALESCE term is the message-activity time; the touch clock advances on
every other persisted change to the session row or its transcript (see the
resurfacing paragraph below), so this field always reflects the most recent
of “when did this session last say something” and “when was this session
row or its messages last written to.” Checkpoint your sync on this field:
- Keep the maximum
last_activity_atyou have seen. - On the next pull, pass it back as
?updated_after=<value>. - The filter is an inclusive
>=against that same expression, so a session gets picked up again whenever anything that advances the watermark happens to it — a new message, an edit, an import/backfill, or any other persisted change to the session — not just on its first appearance.
Never checkpoint on started_after. started_after/started_before
filter on the immutable started_at column and exist only for bounded /
windowed backfill (“give me everything that started in June”). Because
started_at never changes after a session begins, a started_after
checkpoint will never re-surface a session that later received new messages
— using it as an ongoing sync checkpoint silently drops updates.
last_message_at is also returned, as nullable raw metadata: it is
null until a session’s first message and is not the checkpoint field.
Use last_activity_at for checkpointing; use last_message_at only if you
specifically need to know whether a session has any messages yet.
Duplicate rows across pulls are expected and safe: any persisted change that
advances the watermark (message insert/update/delete, historic edit, import,
backfill, metadata change) re-surfaces the row rather than being silently
skipped by a narrower “did anything material change” test. Consumers should
treat every pull as idempotent (upsert by session id).
Known limitation: a session that never received a message, and whose row is later removed by Chipp’s internal empty-session cleanup (rather than the normal delete, which keeps a tombstone row), simply stops appearing in any future pull — there is no persisted row left to re-surface. This does not affect any session that ever had at least one message.
Timestamp format and precision
- Accepted input (
updated_after,started_after,started_before): ISO 8601, UTC or with an explicit offset, e.g.2026-06-15T14:30:00Zor2026-06-15T14:30:00.123Z. - Stored precision: Postgres
TIMESTAMPTZ(microsecond). - Returned/compared precision: millisecond.
- Flooring direction: any precision beyond milliseconds is always
floored (truncated toward the past), never rounded up — both when a
stored microsecond-precision value is serialized in a response, and when a
client-supplied timestamp with sub-millisecond precision is parsed. Because
a floor can only move a value earlier, and the filter is inclusive
>=, checkpointing on a returnedlast_activity_atvalue can never skip a row at a precision boundary — the worst case is seeing a row again, never losing one.
Do not build an incremental sync on started_after + max started_at. A session that started before your checkpoint but received a new message (or any other persisted change — see above) after it never advances started_at — your sync will permanently skip it. Use updated_after and checkpoint on last_activity_at, not on started_at.
Sort order, tie-break, and cursor semantics
/sessions sorts by GREATEST(COALESCE(last_message_at, started_at), sessions.updated_at) DESC, then session id DESC as the tie-break for rows with an identical timestamp. Every session row exposes this exact value as last_activity_at (see above) — that is the value the server sorts, paginates, and filters (updated_after) on, so a client never has to reconstruct it.
/usage sorts by created_at DESC, id DESC.
Cursors are keyset, not snapshot. next_cursor encodes (sort value, id) of the last row you saw and asks Postgres for “everything further down this order” on your NEXT request — it does not pin a consistent view of the data as of when you started paginating. Cursors do not expire. Two concrete consequences follow directly from this:
- New activity ranks ABOVE your cursor and never overlaps a later page. If a session gets a new message (or any other change that advances
last_activity_at) while you are mid-pagination, it jumps to the top of the (still-unpaginated) order — you will not skip or double-count it within THIS pull, because it is strictly newer than every row you have already consumed. - A session that existed on an earlier page can only move further up, never down (the sort key only ever increases on write) — so within a single continuous pagination pass, no row you have not yet reached can silently vanish ahead of you. The real risk is across separate RUNS: if you stop paginating and resume much later using a stale cursor instead of restarting from
updated_after, rows written between runs at a sort position you already passed are permanently missed by that stale cursor. Always drive a sync asupdated_after=<last checkpoint>+ a fresh pagination pass tohas_more: false, never as “resume an old cursor across runs.”
Endpoints
GET /apps
List the org’s apps (non-deleted, newest first).
curl -s "https://dino-mullet.chipp.ai/api/v1/org/apps" \
-H "Authorization: Bearer $CHIPP_ORG_KEY"{
"data": [
{
"id": "uuid",
"name": "Ask Max!",
"slug": "ask-max-8jw2",
"created_at": "2026-04-19T02:33:55.787Z",
"updated_at": "2026-06-04T23:55:47.228Z"
}
]
}GET /sessions
Chat sessions across all your org’s apps, newest first (sort order: see Pagination above).
Query params (unrecognized params return 400, see Errors): limit, cursor, app_id (filter to one app; FORMAT-validated UUID — a malformed value 400s, an unknown-but-valid UUID returns 200 with zero rows), updated_after (ISO 8601 — the incremental sync checkpoint, see above), started_after/started_before (ISO 8601 — bounded backfill only, never the ongoing checkpoint). A malformed or present-empty updated_after/started_after/started_before/app_id value, or a malformed limit, returns 400 immediately — see Immediate validation errors.
# Incremental sync: checkpoint on the last `last_activity_at` you saw.
curl -s "https://dino-mullet.chipp.ai/api/v1/org/sessions?limit=100&updated_after=2026-06-01T00:00:00Z" \
-H "Authorization: Bearer $CHIPP_ORG_KEY"{
"data": [
{
"id": "uuid",
"app_id": "uuid",
"consumer_id": "uuid|null",
"consumer_email": "patient@clinic.com|null",
"title": "Onboarding chat",
"source": "APP",
"external_id": "patient-abc123|null",
"query_params": [
{ "name": "rp_token", "value": "abc123" },
{ "name": "ab_variant", "value": "B" }
],
"started_at": "2026-06-15T14:30:00Z",
"ended_at": "2026-06-15T14:45:00Z",
"last_activity_at": "2026-06-15T14:45:00Z",
"last_message_at": "2026-06-15T14:45:00Z|null"
}
],
"pagination": { "next_cursor": "...", "has_more": true, "limit": 100 }
}last_activity_at is the canonical, always non-null incremental-sync
checkpoint (see “Incremental sync checkpoint” above). last_message_at
is nullable raw metadata (null until the first message) — do not checkpoint
on it.
external_id holds the value passed via ?chipp_external_id= (web sessions), or a channel-namespaced key like whatsapp:+15551234567 (channel sessions). null when not set.
query_params is the [{ name, value }] array of URL query params the consumer arrived with. These are captured at session creation and never change. Chipp control params (bt, session, share) are excluded. Use this for A/B arm attribution, referral token analysis, and campaign tracking — with no side-channel joins required.
GET /sessions/{sessionId}/messages
Messages for one session (must belong to your org).
Query params: include (comma-separated, optional).
Include values:
tool_details— adds a compacttoolsarray to each message that invoked a tool or rendered a component.
# Basic
curl -s "https://dino-mullet.chipp.ai/api/v1/org/sessions/<sessionId>/messages" \
-H "Authorization: Bearer $CHIPP_ORG_KEY"
# With tool markers
curl -s "https://dino-mullet.chipp.ai/api/v1/org/sessions/<sessionId>/messages?include=tool_details" \
-H "Authorization: Bearer $CHIPP_ORG_KEY"{
"data": {
"session": {
"id": "uuid",
"app_id": "uuid",
"consumer_id": "uuid|null",
"consumer_email": "patient@clinic.com|null",
"title": "Onboarding chat",
"source": "APP",
"external_id": "patient-abc123",
"query_params": [{ "name": "rp_token", "value": "abc123" }],
"started_at": "2026-06-15T14:30:00Z"
},
"messages": [
{
"id": "msg-001",
"role": "user",
"content": "Hi, I just signed up.",
"created_at": "2026-06-15T14:30:01Z"
},
{
"id": "msg-002",
"role": "assistant",
"content": "Welcome! Let me walk you through the next steps.",
"created_at": "2026-06-15T14:30:05Z",
"tools": [
{
"id": "toolu_01XYZ",
"name": "render_completion_screen",
"type": "custom_component",
"component": "CompletionScreen",
"success": true
}
]
}
],
"zdr": false
}
}Each tools marker has: { id?, name?, type?, component?, success? }. Heavy blobs (code, input, output) are excluded.
Detect completion: messages.some(m => m.tools?.some(t => t.name === "render_completion_screen"))
If an app has Zero Data Retention (ZDR) enabled, message bodies are ephemeral and not stored.
messagesis empty andzdristrue.
GET /usage
Per-call token usage and cost across the org’s apps, newest first.
Query params: limit, cursor, since (ISO 8601). A malformed or
present-empty since, or a malformed limit, returns 400 immediately —
see Immediate validation errors.
curl -s "https://dino-mullet.chipp.ai/api/v1/org/usage?since=2026-06-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $CHIPP_ORG_KEY"{
"data": [
{
"id": 123,
"app_id": "uuid",
"session_id": "uuid|null",
"consumer_id": "uuid|null",
"model": "claude-sonnet-4-6",
"source": "consumer",
"input_tokens": 1200,
"output_tokens": 340,
"cache_read_input_tokens": 800,
"cache_creation_input_tokens": 0,
"cost_usd": "0.012500",
"created_at": "2026-06-05T14:39:07.415Z"
}
],
"pagination": { "next_cursor": "...", "has_more": true, "limit": 100 }
}session_id is null for sessionless usage (Alchemist copilot, heartbeat). consumer_id is null for builder/anonymous sessions. Consumer email is on the /sessions endpoint (behind the transcripts:read scope) to keep this usage:read endpoint PII-free. Join consumer_id / session_id between the two endpoints.
GET /tombstones
A durable, content-free deletion feed. Every row is a signal that a record you may already have pulled via /sessions (a chat session, or one of its consumers) was deleted in Chipp — so a warehouse that synced it earlier knows to purge its own copy. This is the endpoint that turns Org Export from “add-only” into a true two-way sync source: without it, a deleted record simply stops appearing in /sessions, and nothing tells you why it’s gone or that it’s gone at all.
Requires the tombstones:read scope. Add it when minting a key: “Create an org read API key with the transcripts and tombstones scopes.”
Query params (unrecognized params return 400): limit, cursor (standard pagination — see above), cause (optional exact-match filter — see “Cause values” below; omit it to receive every cause and branch client-side).
curl -s "https://dino-mullet.chipp.ai/api/v1/org/tombstones?limit=100" \
-H "Authorization: Bearer $CHIPP_ORG_KEY"{
"data": [
{
"id": "482913",
"resource_type": "session",
"resource_id": "3f9c2b1a-...",
"cause": "consumer_deleted",
"deleted_at": "2026-06-15T14:45:00.123Z"
},
{
"id": "482910",
"resource_type": "consumer",
"resource_id": "a1b2c3d4-...",
"cause": "retention_expiry",
"deleted_at": "2026-06-15T14:30:11.009Z"
}
],
"pagination": { "next_cursor": "...", "has_more": true, "limit": 100 }
}Fields:
resource_type—"session"or"consumer". Asessiontombstone also covers that session’s messages (they have no independent lifecycle). Aconsumertombstone does not imply that consumer’s sessions were deleted — deleting a consumer never toucheschat.sessions. Act on each resource type independently.resource_id— the same opaque id you already saw asidon/sessionsor asconsumer_id. Never content, never PII.cause— the write-time reason for the deletion. Part of the response contract — see “Cause values and forward compatibility” below.deleted_at— when the deletion happened, millisecond precision.id— a monotonic tie-break, returned as a string (treat as opaque; do not do arithmetic on it). Sort/pagination key is(deleted_at DESC, id DESC).
No deleted content or PII is ever returned by this endpoint. There is no message body, title, or email column on a tombstone row — only the fact and reason of the deletion.
Cause values and forward compatibility
| Cause | Meaning |
|---|---|
consumer_deleted | An explicit session delete or consumer delete, initiated through the product (builder UI, a consumer-facing “delete my conversation” action, or the equivalent API route). |
app_deleted | The session/consumer was removed from the org export view because its app was deleted. |
retention_expiry | Chipp’s anonymous-consumer retention job reclaimed a stale, message-inactive anonymous consumer. |
support_erasure | A support- or compliance-initiated erasure request. Reserved for a future erasure flow — may not appear in your data today, but is part of the contract so your integration is ready when it does. |
unknown | The deletion was recorded before cause tracking was added. The resource_type, resource_id, and deleted_at fields are correct; only the cause was not captured. This is a historical sentinel, not a future value — it appears only in tombstones written during a brief deploy window. Treat it as a conservative erasure/delete (see compliance note below). |
cause is an open string, not a closed enum. Chipp may add a new cause value at any time as new deletion paths ship. Adding a value is an additive, non-breaking change under this doc’s Versioning and breaking changes policy — your client must tolerate a cause it does not recognize, the same way it must tolerate a new optional response field.
If your downstream system branches on cause (e.g. “an erasure must purge immediately, a retention expiry can wait for a batch job”), treat any unrecognized cause — including unknown — as conservative erasure/delete: purge it on the same timeline you would use for consumer_deleted/support_erasure. Over-deleting on a cause you don’t recognize yet is always safer than under-honoring a real erasure request because your integration predates the value.
Cursor lifetime and incremental checkpointing
The /tombstones cursor never expires and is safe to persist as a long-lived incremental checkpoint — it is a pure keyset comparison on (deleted_at, id), so a tombstone written concurrently with your pagination pass is never skipped or duplicated within that pass (same guarantee as /sessions, see “Sort order, tie-break, and cursor semantics” above). Several tombstones sharing the exact same deleted_at millisecond (a realistic case: deleting an app tombstones every one of its sessions in one bulk statement) are still broken deterministically by id.
Safe incremental loop:
- Poll
GET /tombstones?limit=200with no cursor to get the newest page. - Follow
next_cursorolder untilhas_moreisfalse, or until you reach a tombstoneidyour warehouse has already ingested. - On the next run, poll the newest page again (no saved cursor) rather than resuming a cursor from days ago — same guidance as
/sessions. - A client-side retry of the exact same request is always safe: this is a pure read with no side effects, so a retry returns the identical page.
Historical coverage boundary
Tombstone recording has two distinct coverage zones, not one:
Zone 1 — before write-side deploy (no tombstone at all). Chipp’s tombstone write-side landed in a prior release, independent of when this read endpoint shipped to you. Any deletion committed before that instant produced no tombstone row and cannot be reconstructed after the fact. Chipp does not infer or guess a cause for a deletion it did not record, and no backfill of this window is possible — no durable audit source recorded why a session or consumer was deleted before this table existed.
Zone 2 — write-side landed, cause column not yet added (tombstone exists, cause: "unknown"). A brief deploy window between the write-side and the cause-column migration means some tombstone rows carry cause: "unknown". Their resource_type, resource_id, and deleted_at are correct and complete; only the cause was not captured. These rows are honest about what was and was not recorded. Apply the conservative erasure treatment (see compliance callout above).
Zone 3 — full coverage (tombstone + cause). All tombstones written after the cause column landed have a real cause value. This is the region where the feed is fully machine-readable for cause-branching logic.
To find where your org’s full-coverage zone begins, look at the deleted_at of the oldest tombstone with a non-unknown cause. The oldest tombstone of any cause gives the write-side deploy boundary. Both values are directly readable once you page to the end of the feed.
If you need a definitive picture of all deletions from Zone 1 (before write-side deploy), the only reliable path is a one-time enumeration of your own previously-synced IDs against a fresh full pull, retained as a baseline for a later set-difference. /tombstones guarantees complete, gap-free coverage only from the write-side deploy forward.
Warehouse ingestion: tombstones as a purge queue
For a Snowflake (or any warehouse) sync, treat /tombstones as a delete queue rather than a data source to join against your fact tables:
-- After upserting new/changed sessions from /sessions, apply pending
-- tombstones as soft-deletes (or hard deletes, per your retention policy).
-- An unrecognized future `cause` still lands here -- branch on cause only
-- if you need different handling; otherwise treat every row the same.
UPDATE warehouse.sessions
SET is_deleted = TRUE, deleted_at = t.deleted_at, deleted_cause = t.cause
FROM staged_tombstones t
WHERE warehouse.sessions.id = t.resource_id
AND t.resource_type = 'session';
UPDATE warehouse.consumers
SET is_deleted = TRUE, deleted_at = t.deleted_at, deleted_cause = t.cause
FROM staged_tombstones t
WHERE warehouse.consumers.id = t.resource_id
AND t.resource_type = 'consumer';Run this pass after your /sessions incremental pull in the same sync cycle, and checkpoint the tombstone cursor independently of the updated_after sessions checkpoint — the two feeds advance on unrelated clocks.
Errors
{ "error": { "code": "...", "message": "..." } }:
| HTTP | When |
|---|---|
| 400 | A documented filter value is malformed or missing-but-present (see “Immediate validation errors” below), or an unrecognized query parameter name — see “Compatibility notice” below for that one’s enforcement date. |
| 401 | Missing/invalid/expired key |
| 403 | Key lacks the required scope |
| 404 | Session not found (or not in your org) |
| 429 | Rate limited (see Retry-After) |
A 400 means the request was rejected outright — it is never an unfiltered export in disguise. Fix the value and retry; do not treat a 400 as “no results.”
Immediate validation errors (no notice window)
updated_after, started_after, started_before, since, app_id, and limit are validated immediately, every time — there is no compatibility window for these, because the pre-fix fallback for a malformed value (silently drop the filter, or in app_id’s and since’s case, a 500) could produce silently wrong data. A missing-but-present value (e.g. ?since=) is treated the same as a malformed one.
- Timestamp params (
updated_after,started_after,started_before,since): must be ISO 8601 (e.g.2026-06-15T14:30:00Z). A malformed or empty value returns400naming the param, e.g.{ "error": { "code": "bad_request", "message": "Invalid since: must be an ISO 8601 timestamp (e.g. 2026-06-15T14:30:00Z)" } }. app_id: must be a UUID. Only the FORMAT is checked — a syntactically valid UUID that matches no current app is a valid request and returns200with zero rows (not404), so per-app sync loops keep working across an app deletion. A malformed value returns400, e.g.{ "error": { "code": "bad_request", "message": "Invalid app_id: must be a UUID (e.g. 3f9c2b1a-6d4e-4b8a-9c1f-2e7a5d6b8c3d)" } }.limit: must be an integer. A non-numeric value (e.g.abc) returns400. An oversized-but-valid integer (e.g.500) is NOT an error — see “Compatibility notice — oversizedlimit” below.
Compatibility notice — unknown query params (enforcement date: 2026-10-10)
Before 2026-10-10, an unrecognized query parameter is honored with legacy unfiltered behavior (the request succeeds) and flagged via a Warning response header and an X-Chipp-Unknown-Query-Params header naming the offending param(s). On and after 2026-10-10, the same request returns 400. Use this window to audit and fix any integration that sends a param name the endpoint does not recognize. A common case: created_after on /usage was previously silently ignored (the request returned an unfiltered page); it will return 400 starting 2026-10-10. The supported filter is since (see /usage above).
Compatibility notice — oversized limit (no enforcement date; informational only)
A limit above the documented maximum (100) has always clamped to 100 and paginated normally — that does not change here. What’s new is visibility: an oversized-but-valid limit now also gets a Warning response header and an X-Chipp-Limit-Clamped: requested=<n>,clamped_to=100 header, so a caller can detect the clamp instead of quietly getting fewer rows per page than it asked for. There is no enforcement date for this one — clamping is not being phased out. If a future release ever wants to reject an oversized limit instead of clamping it, that would ship as a new, separately-dated compatibility notice, not silently.
Cron / script pull recipe
This loop pulls new transcripts since the last sync and writes them to a local file. Adapt for Snowflake, BigQuery, or any datastore.
Checkpoint on last_activity_at, not started_at. The recipe below is the CORRECTED version — if you built against an older copy of this page that checkpointed on started_at + started_after, switch to updated_after / last_activity_at now: that shape permanently misses a session resumed (or otherwise updated) after your checkpoint (see Pagination above).
const ORG_KEY = process.env.CHIPP_ORG_KEY;
const BASE = "https://dino-mullet.chipp.ai/api/v1/org";
// checkpointIso: the max `last_activity_at` persisted from the PREVIOUS full run.
async function pull(checkpointIso) {
// 1. List apps once to build the name map.
const { data: apps } = await fetch(`${BASE}/apps`, {
headers: { Authorization: `Bearer ${ORG_KEY}` }
}).then(r => r.json());
const appNames = Object.fromEntries(apps.map(a => [a.id, a.name]));
// 2. Page through sessions. `updated_after` is the canonical, always
// non-null incremental checkpoint -- NEVER use `started_after` here
// (it filters on the immutable started_at column and would never
// re-surface a session that later received new messages). It filters
// GREATEST(COALESCE(last_message_at, started_at), <touch clock>) >=
// checkpointIso (inclusive, >=), the SAME expression the server sorts
// on, so a session that started long ago but was just resumed (or
// otherwise updated) re-enters the window. Because the comparison is
// inclusive, the row exactly at checkpointIso reappears in this run --
// step 5's upsert-by-id absorbs the repeat. Always run a fresh
// pagination pass to has_more:false; never resume a saved cursor
// across separate runs (see cursor semantics above).
let cursor, maxLastActivityAt = checkpointIso;
const sessions = [];
do {
const url = new URL(`${BASE}/sessions`);
url.searchParams.set("updated_after", checkpointIso);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const { data, pagination } = await fetch(url, {
headers: { Authorization: `Bearer ${ORG_KEY}` }
}).then(r => r.json());
for (const s of data) {
// 3. Fetch messages with compact tool markers.
const msgUrl = `${BASE}/sessions/${s.id}/messages?include=tool_details`;
const { data: { messages } } = await fetch(msgUrl, {
headers: { Authorization: `Bearer ${ORG_KEY}` }
}).then(r => r.json());
sessions.push({
session_id: s.id,
app_name: appNames[s.app_id],
external_id: s.external_id,
query_params: s.query_params, // [{ name, value }] -- self-attributing
completed: messages.some(m =>
m.tools?.some(t => t.name === "render_completion_screen")
),
messages,
});
// Track the high-water mark for the next run's updated_after.
// Checkpoint on last_activity_at (the canonical watermark), NEVER on
// started_at -- started_at never advances when a session is resumed
// or otherwise updated, so checkpointing on it permanently re-misses
// the row. A session may legitimately reappear across pulls whenever
// anything advances the watermark.
if (s.last_activity_at > maxLastActivityAt) maxLastActivityAt = s.last_activity_at;
}
cursor = pagination.next_cursor;
} while (cursor);
// 4. Only persist the new checkpoint after a FULL pass (has_more:false).
// A partial-run checkpoint can skip whatever the run didn't reach yet.
// nextCheckpoint is used as updated_after on the next run. Because that
// filter is inclusive (>=), the row at exactly nextCheckpoint will
// reappear -- the upsert in step 5 turns that into a harmless no-op.
return { sessions, nextCheckpoint: maxLastActivityAt };
}
// 5. Write each session to your destination as an idempotent UPSERT keyed
// on session id (not on started_at or any composite key). Because
// updated_after is inclusive, consecutive runs overlap at the boundary
// row -- without a keyed upsert you get duplicate rows.
//
// Postgres:
// INSERT INTO sessions (id, app_id, last_activity_at, ...)
// VALUES ($1, $2, $3, ...)
// ON CONFLICT (id) DO UPDATE
// SET app_id = EXCLUDED.app_id,
// last_activity_at = EXCLUDED.last_activity_at, ...;
//
// Snowflake / BigQuery MERGE:
// MERGE INTO sessions AS tgt
// USING (SELECT :id AS id, :app_id AS app_id, :last_activity_at AS last_activity_at, ...) AS src
// ON tgt.id = src.id
// WHEN MATCHED THEN UPDATE SET tgt.last_activity_at = src.last_activity_at, ...
// WHEN NOT MATCHED THEN INSERT (id, app_id, last_activity_at, ...) VALUES (src.id, ...);
//
// Apply the same upsert-by-id pattern to message rows.
//
// Any persisted change to a session or its transcript (message
// insert/update/delete, historic edit, import/backfill, metadata change)
// advances last_activity_at and re-surfaces the row on the next pull --
// duplicate delivery is expected and absorbed by the upsert above.Attribution pattern: Because query_params includes the arrival link’s params (e.g. rp_token, ab_variant), each row in sessions is already attributed — no join to an external mapping table required.
Checkpoint-and-merge summary: The nextCheckpoint value returned above becomes the next call’s updated_after. Because the filter is inclusive (>=), the row at exactly nextCheckpoint reappears in the next run. Your destination table MUST key its upsert on session id (step 5) so the repeat delivery is a no-op.
Relationship to the Builder API
Builder API (chipp_) | Org Export API (chipp_org_*) | |
|---|---|---|
| Scope | One app | All apps in org |
| Auth | Per-app key | Org-level key |
| Scripting | Manual per-app loop | Single paginated pull |
| ZDR | Returns empty messages | Returns empty messages |
| Tool markers | Raw tool_calls / tool_results blobs | Compact tools markers (no code) |
For the Builder API’s per-app sessions and messages endpoints, see Sessions.
Versioning and breaking changes
/api/v1/org/* is the current, stable version of this API.
- Additive changes ship without notice. A new optional query parameter, a new field on a response object (like
last_activity_atabove), or a new endpoint never breaks an existing integration — code that ignores fields it doesn’t recognize keeps working. Build your integration that way: read the fields you need and ignore the rest. - We do not silently change the meaning of an existing field or parameter, or remove one, on
/api/v1. A change that would alter behavior for an existing, correctly-formed request ships as a NEW version path (/api/v2/org/*) rather than mutating/api/v1under your feet./api/v1keeps working after a/api/v2ships; there is no forced-migration deadline baked into this statement, because Chipp does not control your migration timeline. - How you will hear about it: every platform change, including any future
/api/v2/org/*, is published as a dated entry on the release notes feed — read the public RSS/Atom feed, or register a webhook subscription filtered to an area likeorg exportororg APIto get a push notification the moment it publishes. This is the same mechanism Chipp already uses for every other platform change; we are not promising a new channel that doesn’t exist today. - Unsupported/unrecognized query parameters fail closed (
400) after a 60-day compatibility notice window — see Errors for the enforcement date and transition behavior. During the notice window (before 2026-10-10) an unknown param is honored with aWarningheader rather than rejected, giving unattended nightly jobs time to be fixed. After 2026-10-10, the same request returns400.