One envelope, three doors
The console, the CSV upload and the future SDK all speak this contract. Freezing it now is what makes them one system instead of three — and it is why the CSV door needed no separate validation path, no separate audit trail and no separate set of rules.
| METHOD | PATH | PURPOSE |
|---|---|---|
| POST | /v1/oauth/token | Signed JWT assertion → 60-minute access token |
| GET | /v1/projects/{id}/datasets/{ds}/schema | The contract, versioned |
| POST | /v1/projects/{id}/datasets/{ds}:push | Headers Idempotency-Key, X-ACF-Period. Returns 202 + receipt_id |
| GET | /v1/receipts/{receiptId} | Status, per-row errors |
| GET | /v1/projects/{id}/deliveries?from=&to= | The expectation calendar — a client can check its own history |
{
"dataset": "telesales_calls_daily",
"period": "2026-09-22", "grain": "day", "org_unit": "SALES-TS",
"submitted_at": "2026-09-23T02:04:11+07:00",
"producer": "stringeex-exporter/1.4.0",
"rows": [
{ "metric_id": "TS-01", "org_unit": "SALES-TS",
"period": "2026-09-22", "value": 738769, "unit": "count" }
],
"row_count": 1,
"checksum": "sha256:4c1e…"
}A CSV row becomes one rows[] entry. There is one validation path, one audit trail, one set of rules — which door the data came through is provenance, not a separate pipeline.
- 400schema_invalid
The payload does not match the dataset version. The whole batch is refused.
- 409period_closed
That period is locked. Reopen it through the metric owner, not through a retry.
- 409restatement_requires_reason
A value already exists and differs. Send `reason` — this raises a trend break marker.
- 422value_implausible
Outside the plausibility range in the registry. Quarantined, with an alert.
- 423project_suspended
The circuit breaker tripped. A human must resume it — retrying will not.
- 429rate_limited
Back off. Retry-After is set.
- 503retry_after
We are down, not you. The idempotency key makes your retry safe.
import { AcfKpi } from '@acf/kpi-sdk'
const client = new AcfKpi({ keyFile: './acf-kpi-service.json' })
// signs the assertion, retries idempotently, and validates against the
// cached schema BEFORE the network call — so a bad batch fails here,
// on your laptop, not in our quarantine.
const receipt = await client
.dataset('telesales_calls_daily')
.push({ period: '2026-09-22', rows })
if (receipt.state === 'rejected') {
console.table(receipt.errors) // your file rows, not batch offsets
}from acf_kpi import Client
client = Client.from_key_file("acf-kpi-service.json")
receipt = client.dataset("finance_monthly_upload").push(
period="2026-09", rows=rows,
)
receipt.raise_for_status()Three things the REST contract cannot do on its own: it signs the JWT assertion, it retries idempotently, and it validates against the cached schema before the network call — so a malformed batch fails on the department's laptop rather than in our quarantine, and the person who can fix it is the person looking at the error. Both are generated from the OpenAPI spec, so an SDK cannot drift from the contract. A .NET SDK only if a source team asks for one.
Versioning: a breaking dataset change creates v2 and v1 keeps being accepted for 90 days, with the countdown on the project page. Nobody's exporter breaks overnight because we tightened a type.