One API over everything you run.
A versioned REST API against the hub. Bearer-authenticated, cursor-paginated, problem+json on failure, and returning the same canonical records the dashboard shows — because the dashboard is built on this.
Authentication
Every request carries a bearer key in the Authorization header and a dated version in APIfl0w-Version. Requests without a version header are served the version pinned to your hub at onboarding, which means an un-versioned integration will keep working — but you should pin it anyway.
| Header | Required | Notes |
|---|---|---|
Authorization | Yes | Bearer ak_live_… or Bearer ak_test_… |
APIfl0w-Version | Recommended | Dated schema version, e.g. 2026-05-01 |
Idempotency-Key | Writes only | Any opaque string; replays return the original response for 24 h |
Accept | No | application/json is the only representation |
Key prefixes
ak_live_ keys read production records from your hub. ak_test_ keys read a mirrored sandbox hub seeded with synthetic records that share the canonical schema exactly — same fields, same lineage shape, no real customer data. Test keys never touch production and can be issued freely to contractors.
Rotation
Keys are rotated from the portal. A rotation issues the new key immediately and marks the old one retiring for a grace window you choose, up to 30 days; both work during the window, and the portal shows last-used timestamps per key so you can confirm every caller has moved before the old key stops. Compromised keys can be revoked instantly, which skips the grace window and returns 401 to anything still using them.
export APIFLOW_KEY="ak_live_7Qd2mVx9RtL0"
curl "https://api.apifl0w.com/v1/records?type=lead&limit=1" \
-H "Authorization: Bearer $APIFLOW_KEY" \
-H "APIfl0w-Version: 2026-05-01"
Records
GET /v1/records
Returns canonical records from the hub, newest first by occurred_at. Every record is the merged product of one or more upstream systems; the sources array names them and the optional lineage block says which one supplied each individual field.
Filter parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
type | string | all | One of lead, session, deal, order, message, ticket, spend. Repeatable. |
since / until | string | — | RFC 3339 bounds on occurred_at. Half-open: since inclusive, until exclusive. |
updated_since | string | — | Bounds on hub write time. Use this for incremental loads — merges change records without changing occurred_at. |
source | string | — | Category slug, e.g. crm. Matches records with that system anywhere in sources. |
connector_id | string | — | Narrower than source: a specific operated connector. |
phone / email | string | — | Exact match on the normalized identity field. Phones must be E.164. |
include | string | — | lineage, raw, or both comma-separated. raw requires a key with the raw-payload scope. |
limit | integer | 50 | 1–500. |
cursor | string | — | Opaque cursor from next_cursor. See pagination. |
Canonical record schema
| Field | Type | Meaning |
|---|---|---|
id | string | Hub identifier, rec_ + ULID. Stable across merges. |
type | string | One of the seven canonical record types. |
occurred_at | string | When the event happened upstream, RFC 3339 UTC. |
received_at | string | When the fabric committed it. Always ≥ occurred_at. |
phone | string · null | E.164. The primary identity key for lead. |
email | string · null | Lower-cased, trimmed. |
name | string · null | Free text as supplied; never parsed into parts. |
value | object · null | { amount, currency } — minor units and ISO 4217. |
duration_s | integer · null | Call or interaction duration in seconds. |
engaged_s | integer · null | On-site engaged time in seconds. Deliberately distinct from duration_s. |
sources | array | Category slugs contributing to this record. Unions, never shrinks. |
tags | array | Client-defined labels applied by mapping rules. |
lineage | object | Per-field provenance: "field": "connector_id @ sync_id". Requires include=lineage. |
hub_version | string | Canonical schema version this representation was rendered in. |
Field lineage
Lineage is why a unified dashboard survives its first disagreement. When two systems supply the same field, the fabric keeps one value by the conflict-resolution rules and records where it came from — plus what it beat. A merged lead whose phone came from call tracking and whose deal value came from the CRM says exactly that, per field, forever.
GET /v1/records/{id}
Returns a single record. Accepts include=lineage,raw. Returns 410 Gone with a tombstoned problem type when the upstream deleted the source object and the fabric tombstoned the canonical record — the lineage remains readable.
Connectors
GET /v1/connectors
Lists the connectors operated for your hub, with health. This is the same data behind the fabric strip in the portal.
{
"data": [
{
"id": "con_calltrack_01",
"category": "call_tracking",
"display_name": "Call tracking — main line",
"status": "healthy",
"cadence": "15min",
"last_sync_at": "2026-08-11T14:00:11Z",
"next_window_at": "2026-08-11T14:15:00Z",
"records_last_24h": 1842,
"yields": ["lead", "message"]
},
{
"id": "con_seo_01",
"category": "seo",
"display_name": "Search & SEO — parts catalogue",
"status": "degraded",
"cadence": "hourly",
"last_sync_at": "2026-08-11T12:00:04Z",
"next_window_at": "2026-08-11T15:00:00Z",
"records_last_24h": 0,
"note": "Upstream has returned 503 since 12:07 UTC. Windows are queued and will replay.",
"yields": ["session"]
}
],
"hub_version": "2026-05-01"
}
| Status | Meaning | Portal treatment |
|---|---|---|
healthy | Windows closing on cadence, error rate within threshold | Green ring |
degraded | Two consecutive missed windows or an elevated error rate | Amber ring, plain-language note |
halted | Auth failure or an upstream lockout risk; a human is on it | Pink ring, incident linked |
paused | Retired by the client; history stays queryable | Dimmed node |
backfilling | Historical load in progress alongside live windows | Green ring, backfill chip |
GET /v1/connectors/{id}
Adds the connector's mapping table, its declared cadence and rate-limit budget, and the last 50 sync summaries.
Syncs
GET /v1/syncs
One object per sync run — the same objects your invoice counts as connector-syncs. Filter with connector_id, status (completed, failed, running), since and until.
{
"id": "syn_88f21c",
"connector_id": "con_calltrack_01",
"status": "completed",
"window_start": "2026-08-11T13:45:00Z",
"window_end": "2026-08-11T14:00:00Z",
"started_at": "2026-08-11T14:00:02Z",
"finished_at": "2026-08-11T14:00:11Z",
"duration_ms": 9412,
"attempts": 1,
"records_created": 37,
"records_merged": 4,
"dropped_fields": ["utm_soup", "legacy_ref"],
"dead_lettered": 0
}
Pagination
Every list endpoint uses opaque forward cursors. Read next_cursor from the response and pass it back as cursor; when next_cursor is null you have the whole set. Cursors encode a stable sort position, so pages do not shift or repeat when new records arrive mid-walk — the thing offset pagination gets wrong on a live fabric.
Cursors expire after 24 hours. An expired cursor returns 400 with type cursor-expired; restart from updated_since rather than from the beginning.
let cursor = null;
const all = [];
do {
const qs = new URLSearchParams({ type: "lead", limit: "500" });
if (cursor) qs.set("cursor", cursor);
const res = await fetch(`https://api.apifl0w.com/v1/syncs?${qs}`, {
headers: {
Authorization: `Bearer ${process.env.APIFLOW_KEY}`,
"APIfl0w-Version": "2026-05-01"
}
});
if (!res.ok) throw new Error(`apiflow ${res.status}`);
const page = await res.json();
all.push(...page.data);
cursor = page.next_cursor;
} while (cursor);
Webhooks
Register endpoints in the portal. Every delivery carries APIfl0w-Signature: t=<unix>,v1=<hex> — an HMAC-SHA256 over timestamp + "." + rawBody using your endpoint secret. Verify before parsing, reject anything older than five minutes, and compare in constant time.
import crypto from "node:crypto";
export function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("="))
);
const timestamp = parts.t;
const received = parts.v1;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false; // replay window closed
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
| Event | Payload root | Retry |
|---|---|---|
record.created | record | 8 attempts, exponential to 6 h |
record.merged | record, merged_from[] | 8 attempts, exponential to 6 h |
sync.completed | sync | 4 attempts, exponential to 30 m |
sync.failed | sync, problem | 8 attempts, exponential to 6 h |
connector.degraded | connector | 8 attempts, exponential to 6 h |
connector.restored | connector | 4 attempts, exponential to 30 m |
schema.migrated | migration | 4 attempts, exponential to 30 m |
Deliveries are at-least-once. Make handlers idempotent on event.id; an endpoint returning any 2xx is considered delivered, and anything else is retried on the schedule above. Failed deliveries are replayable from the portal for 30 days.
Errors
Failures return application/problem+json per RFC 7807 with a stable type URI, a human title, the HTTP status, and a detail written for the person reading a log at 02:00 rather than for a compliance document.
{
"type": "https://apifl0w.com/errors/rate-limited",
"title": "Rate limited",
"status": 429,
"detail": "You have made 1,204 requests in the last 60s; the ceiling is 1,200. Retry after 14s. Consider raising limit and paginating less often.",
"retry_after_s": 14,
"request_id": "req_01J9KB7V3S4M0P8QF2ZC6XR1N"
}
| Status | Type slug | What it actually means |
|---|---|---|
400 | invalid-parameter | A filter value did not parse. The detail names the parameter and the value it choked on. |
400 | cursor-expired | A cursor older than 24 h. Restart from updated_since, not from page one. |
401 | invalid-key | Missing, malformed or revoked bearer key. Rotated keys in their grace window do not land here. |
403 | scope-required | The key is valid but lacks a scope — usually include=raw on a key without raw-payload access. |
404 | not-found | No such record, connector or sync in your hub. We never leak whether it exists in someone else's. |
410 | tombstoned | The upstream deleted the source object. The canonical record is gone; its lineage is not. |
422 | version-unsupported | The requested APIfl0w-Version predates your hub's oldest live schema. Migration windows run 90 days. |
429 | rate-limited | 1,200 requests per minute per key. retry_after_s is authoritative. |
503 | upstream-unavailable | Surfaced on sync objects, not on reads. Your hub stays readable while an upstream is down. |
Every response — success or failure — carries request_id. Quote it to us and we can trace the call end to end without asking you to reproduce anything.
Changelog
duration_s for analytics-sourced records. Added updated_since to /v1/records for correct incremental loads across merges. 2025-11-01 remains readable until 2026-07-30.
include=lineage, replacing the flat source_system string. Cursor pagination replaced offsets on every list endpoint. connector.degraded and connector.restored webhooks added.
value moved to { amount, currency } in minor units, ending an argument about rounding that had run for two quarters. Added the spend record type for advertising and finance connectors.
The unified API has been dated and versioned since 2023. No version has ever been withdrawn without a 90-day overlap.
Want a test key to poke at?
We will issue an ak_test_ key against a sandbox hub seeded with synthetic records in the canonical schema — same shapes, no real data — before anyone signs anything.