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-AppCheckheader is ignored (not required, not validated). - No reconciliation worker. Step ingest is synchronous: validation passes →
StepLog.reconciliationStatus = ACCEPTED→ streak advances on the same request.reconcileEtais omitted from the response. provisionalis alwaysfalsein 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
StepLogis 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_ABSENTare 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
| Field | Value |
|---|---|
| Method | POST |
| Path | /step/ingest |
| Auth | Internal session JWT (Bearer) |
| Idempotent | Yes via idempotencyKey |
| Rate limit | 50 req/min/walker. Excess returns 429. |
Request
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer <internal-session-JWT> |
X-Firebase-AppCheck | yes | Fresh App Check token, minted within prior 5 minutes |
Content-Type | yes | application/json |
Idempotency-Key | optional alt to body field | Mirrors 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"}| Field | Type | Required | Notes |
|---|---|---|---|
day | ISO date | yes | Local date of the walk in walker’s tz. May not be > tomorrow; may not be > 7 days past (D-009 cap). |
count | int 0..50000 | yes | Authenticated step count from HealthKit/HC for that day. Hard cap 50_000. |
source | enum | yes | HealthKit | HealthConnect | WatchNative |
tz | IANA tz string | yes | Walker’s tz at time of sample. TZ-jump check compares to last submission. |
sampleSpan | object | yes | UTC start/end of the HealthKit/HC sample window used for count. Burst-rate guard divides count by span duration. |
sourceBundleId | string | yes | OS-level data source bundle id (e.g. com.apple.health, com.google.android.apps.healthdata, com.apple.watch). Must be in server whitelist. |
gyroSamplesObserved | bool | yes | Mobile reports whether the OS confirmed gyroscope samples accompanied step measurements. false triggers QUARANTINE per ADR-0004 Layer 2. |
clientSubmittedAt | ISO datetime | yes | Mobile clock at submission time. Used only for diagnostics. |
idempotencyKey | string (ULID) | yes | Per-bucket unique key. Server deduplicates by this. |
deviceModel | string | optional | Diagnostic only. |
appVersion | string | yes | semver+build. Used for client-side bug correlation. |
Server flow (abbreviated — full pipeline in ADR-0002)
- App Check verification (Firebase Admin). 401 on fail. AttestationLog row written either way.
- Session JWT verification. 401 on fail.
- Idempotency lookup — if
idempotencyKeyalready accepted for this walker, return cached response. - Anti-cheat Layer 2 checks (ADR-0004) — reject 422 with reason code on any violation.
- UPSERT
StepLogfor walker × day:- If row exists and
acceptedCountalready 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.
- If row exists and
- Enqueue
reconcile-stepsCloud Task with 60s delay. - Compute provisional Energy:
tierMult = StreakState.bonusTier multiplier(1.00 / 1.20 / 1.50)provisionalEnergy = floor(count * tierMult)
- 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_EXCEEDEDTZ_JUMP_DETECTEDDAY_IN_FUTUREOFFLINE_CAP_EXCEEDED(day > 7 days past)SOURCE_NOT_WHITELISTEDGYRO_ABSENT(QUARANTINE-class — server returns 200 withprovisional: 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.