Skip to content

API: POST /step/ingest

POST /step/ingest

Submit a per-day step bucket from the mobile client. Server validates attestation, writes a StepLog row in PENDING state, runs reconciliation asynchronously, and returns immediate provisional Energy + streak state.

Status: draft contract — implementation in phase 8b. Related: ADR-0002 (full pipeline), ADR-0003 (streak), ADR-0004 (anti-cheat layers).

Test-phase mock mode

Phase 8b ships against the test-phase infrastructure per ADR-0006, not the production target. Differences from the contract below:

  • No App Check token requirement. X-Firebase-AppCheck header is ignored (not required, not validated).
  • No reconciliation worker. Step ingest is synchronous: validation passes → StepLog.reconciliationStatus = ACCEPTED → streak advances on the same request. reconcileEta is omitted from the response.
  • provisional is always false in mock mode. Allocations made against ingested steps are confirmed immediately. Phase 11 production migration re-introduces the provisional/reconciled split.
  • Streak still computes, but in trust-mode: a day is considered attested whenever a StepLog is accepted by Layer-2 validation (schema + impossible-burst + hard cap + day-in-future). The Firebase App Check attestation gate from ADR-0003 check #2 is not enforced.
  • Reduced reject set. The 422 reasons that fire are limited to: COUNT_EXCEEDS_CAP, BURST_RATE_EXCEEDED, DAY_IN_FUTURE. TZ_JUMP_DETECTED, OFFLINE_CAP_EXCEEDED, SOURCE_NOT_WHITELISTED, GYRO_ABSENT are all deferred to the production-migration phase.
  • No QUARANTINE band. Mock mode is binary: PASS or REJECT.

The production contract below describes the target state, unfrozen when the production migration is greenlit.

Endpoint

FieldValue
MethodPOST
Path/step/ingest
AuthInternal session JWT (Bearer)
IdempotentYes via idempotencyKey
Rate limit50 req/min/walker. Excess returns 429.

Request

Headers

HeaderRequiredNotes
AuthorizationyesBearer <internal-session-JWT>
X-Firebase-AppCheckyesFresh App Check token, minted within prior 5 minutes
Content-Typeyesapplication/json
Idempotency-Keyoptional alt to body fieldMirrors idempotencyKey

Body

{
"day": "2026-05-18",
"count": 8421,
"source": "HealthKit",
"tz": "Europe/Warsaw",
"sampleSpan": {
"startUtc": "2026-05-18T05:00:00Z",
"endUtc": "2026-05-18T20:42:11Z"
},
"sourceBundleId": "com.apple.health",
"gyroSamplesObserved": true,
"clientSubmittedAt": "2026-05-18T20:42:30Z",
"idempotencyKey": "step-ing-01HM4...",
"deviceModel": "iPhone15,4",
"appVersion": "1.0.0+1"
}
FieldTypeRequiredNotes
dayISO dateyesLocal date of the walk in walker’s tz. May not be > tomorrow; may not be > 7 days past (D-009 cap).
countint 0..50000yesAuthenticated step count from HealthKit/HC for that day. Hard cap 50_000.
sourceenumyesHealthKit | HealthConnect | WatchNative
tzIANA tz stringyesWalker’s tz at time of sample. TZ-jump check compares to last submission.
sampleSpanobjectyesUTC start/end of the HealthKit/HC sample window used for count. Burst-rate guard divides count by span duration.
sourceBundleIdstringyesOS-level data source bundle id (e.g. com.apple.health, com.google.android.apps.healthdata, com.apple.watch). Must be in server whitelist.
gyroSamplesObservedboolyesMobile reports whether the OS confirmed gyroscope samples accompanied step measurements. false triggers QUARANTINE per ADR-0004 Layer 2.
clientSubmittedAtISO datetimeyesMobile clock at submission time. Used only for diagnostics.
idempotencyKeystring (ULID)yesPer-bucket unique key. Server deduplicates by this.
deviceModelstringoptionalDiagnostic only.
appVersionstringyessemver+build. Used for client-side bug correlation.

Server flow (abbreviated — full pipeline in ADR-0002)

  1. App Check verification (Firebase Admin). 401 on fail. AttestationLog row written either way.
  2. Session JWT verification. 401 on fail.
  3. Idempotency lookup — if idempotencyKey already accepted for this walker, return cached response.
  4. Anti-cheat Layer 2 checks (ADR-0004) — reject 422 with reason code on any violation.
  5. UPSERT StepLog for walker × day:
    • If row exists and acceptedCount already finalised → return 200 with existing state.
    • If row exists and PENDING → merge reportedCount = max(existing, this.count).
    • If row does not exist → insert with PENDING.
  6. Enqueue reconcile-steps Cloud Task with 60s delay.
  7. Compute provisional Energy:
    • tierMult = StreakState.bonusTier multiplier (1.00 / 1.20 / 1.50)
    • provisionalEnergy = floor(count * tierMult)
  8. Return 200 with provisional Energy + StreakState snapshot.

Response

200 OK

{
"accepted": true,
"provisional": true,
"stepLog": {
"day": "2026-05-18",
"reportedCount": 8421,
"acceptedCount": null,
"reconciliationStatus": "PENDING"
},
"provisionalEnergy": 10105,
"streakState": {
"currentLengthDays": 14,
"lastAttestedDate": "2026-05-17",
"bonusTier": "T1_7D",
"decayAt": "2026-05-19"
},
"walker": {
"totalLifetimeSteps": 472_300,
"currentRegionId": "region.plenny"
},
"reconcileEta": "2026-05-18T20:43:30Z"
}

200 OK (idempotent replay)

{
"accepted": true,
"provisional": false,
"stepLog": {
"day": "2026-05-18",
"reportedCount": 8421,
"acceptedCount": 8421,
"reconciliationStatus": "ACCEPTED"
},
"provisionalEnergy": 10105,
"energyCredited": 10105,
"streakState": {
"currentLengthDays": 15,
"lastAttestedDate": "2026-05-18",
"bonusTier": "T1_7D",
"decayAt": "2026-05-20"
}
}

401 Unauthorized

App Check or session JWT failed verification.

403 Forbidden

{
"error": "STEP_SOURCE_NOT_WHITELISTED",
"message": "sourceBundleId is not recognized.",
"details": { "sourceBundleId": "..." }
}

422 Unprocessable Entity (anti-cheat reject)

{
"error": "STEP_REJECTED",
"message": "Submitted step bucket failed anti-cheat validation.",
"details": {
"reasons": ["BURST_RATE_EXCEEDED", "TZ_JUMP_DETECTED"],
"day": "2026-05-18"
}
}

Reason codes:

  • COUNT_EXCEEDS_CAP (>50_000)
  • BURST_RATE_EXCEEDED
  • TZ_JUMP_DETECTED
  • DAY_IN_FUTURE
  • OFFLINE_CAP_EXCEEDED (day > 7 days past)
  • SOURCE_NOT_WHITELISTED
  • GYRO_ABSENT (QUARANTINE-class — server returns 200 with provisional: true + warning instead of 422; only deterministic rejects use 422)

429 Too Many Requests

50 req/min/walker cap exceeded. Retry-After header included.

Quarantine response shape (special case)

When Layer 2 or Layer 3 issues QUARANTINE rather than REJECT, the server returns 200 with provisional: true and a warning field. UI shows a “we’re validating your last walk” toast.

{
"accepted": true,
"provisional": true,
"warning": {
"code": "STEP_QUARANTINED",
"message": "We're checking this step bucket. It may take up to 24h to confirm.",
"reasons": ["GYRO_ABSENT"]
},
"stepLog": { "day": "2026-05-18", "reportedCount": 8421, "reconciliationStatus": "QUARANTINED" },
"provisionalEnergy": 10105,
"streakState": { "currentLengthDays": 14, "lastAttestedDate": "2026-05-17", "bonusTier": "T1_7D", "decayAt": "2026-05-19" }
}

Caching

Cache-Control: no-store, private. Idempotency is the only replay protection.

Telemetry

Every call emits an info-level structured log (no step counts in the log line — only verdict, reasonCount, latency). Anti-cheat verdicts emit additionally to the anti-cheat sink (ADR-0004).

Open follow-ups

  • Challenge re-read handshake: phase 11 adds a server-prompted re-read of HealthKit/HC for a specific day window. Closed beta uses single-submission with monotonic-delta validation.
  • Batch ingest endpoint (POST /step/ingest:batch): for offline-sync flush of multiple days in one round-trip. Phase 11.