API reference

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.

base api.apifl0w.com/v1 version 2026-05-01 versioned since 2023
records · v1
curl "https://api.apifl0w.com/v1/records?type=lead&limit=1" -H "Authorization: Bearer ak_live_7Qd2mVx9RtL0" { "data": [{ "id": "rec_01J9K7M2QXB4S0E7VN3TD8HZ", "type": "lead", "occurred_at": "2026-08-11T14:02:17Z", "phone": "+14155550142", "duration_s": 214, "engaged_s": 96, "value": { "amount": 482000, "currency": "USD" }, "sources": ["call_tracking", "seo", "analytics", "crm"] }], "next_cursor": "cur_9f21c4b8", "hub_version": "2026-05-01"}

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.

Required and optional request headers
HeaderRequiredNotes
AuthorizationYesBearer ak_live_… or Bearer ak_test_…
APIfl0w-VersionRecommendedDated schema version, e.g. 2026-05-01
Idempotency-KeyWrites onlyAny opaque string; replays return the original response for 24 h
AcceptNoapplication/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.

a minimal authenticated call
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

Query parameters accepted by the records endpoint
ParameterTypeDefaultDescription
typestringallOne of lead, session, deal, order, message, ticket, spend. Repeatable.
since / untilstringRFC 3339 bounds on occurred_at. Half-open: since inclusive, until exclusive.
updated_sincestringBounds on hub write time. Use this for incremental loads — merges change records without changing occurred_at.
sourcestringCategory slug, e.g. crm. Matches records with that system anywhere in sources.
connector_idstringNarrower than source: a specific operated connector.
phone / emailstringExact match on the normalized identity field. Phones must be E.164.
includestringlineage, raw, or both comma-separated. raw requires a key with the raw-payload scope.
limitinteger501–500.
cursorstringOpaque cursor from next_cursor. See pagination.

Canonical record schema

Fields present on every canonical record
FieldTypeMeaning
idstringHub identifier, rec_ + ULID. Stable across merges.
typestringOne of the seven canonical record types.
occurred_atstringWhen the event happened upstream, RFC 3339 UTC.
received_atstringWhen the fabric committed it. Always ≥ occurred_at.
phonestring · nullE.164. The primary identity key for lead.
emailstring · nullLower-cased, trimmed.
namestring · nullFree text as supplied; never parsed into parts.
valueobject · null{ amount, currency } — minor units and ISO 4217.
duration_sinteger · nullCall or interaction duration in seconds.
engaged_sinteger · nullOn-site engaged time in seconds. Deliberately distinct from duration_s.
sourcesarrayCategory slugs contributing to this record. Unions, never shrinks.
tagsarrayClient-defined labels applied by mapping rules.
lineageobjectPer-field provenance: "field": "connector_id @ sync_id". Requires include=lineage.
hub_versionstringCanonical 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.

200 · application/json
{
  "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"
}
Connector status values
StatusMeaningPortal treatment
healthyWindows closing on cadence, error rate within thresholdGreen ring
degradedTwo consecutive missed windows or an elevated error rateAmber ring, plain-language note
haltedAuth failure or an upstream lockout risk; a human is on itPink ring, incident linked
pausedRetired by the client; history stays queryableDimmed node
backfillingHistorical load in progress alongside live windowsGreen 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.

a sync object
{
  "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.

walking a full page set
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.

signature verification — Node
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);
}
Webhook events and retry semantics
EventPayload rootRetry
record.createdrecord8 attempts, exponential to 6 h
record.mergedrecord, merged_from[]8 attempts, exponential to 6 h
sync.completedsync4 attempts, exponential to 30 m
sync.failedsync, problem8 attempts, exponential to 6 h
connector.degradedconnector8 attempts, exponential to 6 h
connector.restoredconnector4 attempts, exponential to 30 m
schema.migratedmigration4 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.

429 · application/problem+json
{
  "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"
}
Problem types returned by the unified API
StatusType slugWhat it actually means
400invalid-parameterA filter value did not parse. The detail names the parameter and the value it choked on.
400cursor-expiredA cursor older than 24 h. Restart from updated_since, not from page one.
401invalid-keyMissing, malformed or revoked bearer key. Rotated keys in their grace window do not land here.
403scope-requiredThe key is valid but lacks a scope — usually include=raw on a key without raw-payload access.
404not-foundNo such record, connector or sync in your hub. We never leak whether it exists in someone else's.
410tombstonedThe upstream deleted the source object. The canonical record is gone; its lineage is not.
422version-unsupportedThe requested APIfl0w-Version predates your hub's oldest live schema. Migration windows run 90 days.
429rate-limited1,200 requests per minute per key. retry_after_s is authoritative.
503upstream-unavailableSurfaced 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

2026-05-01 · current Added engaged_s to the canonical envelope, separating on-site engaged time from call duration — the two had been colliding in 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.
2025-11-01 Introduced per-field lineage under include=lineage, replacing the flat source_system string. Cursor pagination replaced offsets on every list endpoint. connector.degraded and connector.restored webhooks added.
2025-03-01 Canonical 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.