HOCO B2B
SRSAPI reference

Offline sync & location

How the offline-first delegate app talks to the server: ordered replay of queued mutations with per-item results, the delta feed of its offline scopes with tombstones, and batched location pings during a tour.

The delegate app is offline-first (cahier §7.3, SYN-03 to SYN-06): it works on its local database, records every action in an outbox, and talks to the server through three operations. sync.batch replays the outbox in order; sync.delta brings back what changed on the server; sync.locationPings uploads the position samples of the tour. The architecture behind them (local schema, outbox states, retry policy, conflict UX) is in the offline sync design; this page is the contract.

The three guarantees.

  1. Nothing is applied twice. Every queued mutation carries the Idempotency-Key it would carry as a direct call; the server keeps one IdempotencyRecord per key (scope = the user, 30 days) whether the mutation arrived directly or inside a batch. A replay returns the stored result.
  2. Nothing is applied out of order. Items of a batch run one after the other in the order sent, each in its own transaction, exactly as the direct operation would (same validation, scope, guards, events). dependsOn lets an item be skipped when an item it needs failed.
  3. Nothing fails silently. Every item gets its own result (SyncItemResult); a conflict on one item never fails the batch. The app shows pending, failed and conflicted items in the sync centre (DA-27) with the states synchronisé, en attente, en erreur (cahier §7.3).

Scope. Every item is executed with the caller's delegate scope as it is at execution time, not as it was when the action happened offline; a record that left the scope meanwhile comes back as a conflict.

MethodPathOperationAccessAppsRelease
POST/sync/batchsync.batch
Replay up to 50 queued offline mutations in order; per-item results with idempotent replays
delegateDAV1
GET/sync/deltasync.delta
Changes since a cursor for the delegate's offline scopes (tours, visits, resellers, tasks, reference lists)
delegateDAV1
POST/delegate/location-pingssync.locationPings
Batch of position samples during an active tour (policy-driven frequency)
delegate
Idempotency-Key
DAV1

Conventions on this page

  • Caller: delegate session; mobile headers on every call. Clock: the app stamps occurredAt with its device clock corrected by the offset it derives from serverTime (returned by every response of this page and by the today snapshot), so a phone with a wrong clock does not produce OCCURRED_AT_OUT_OF_RANGE refusals.
  • Errors common to every operation, not repeated: 400 VALIDATION_FAILED, 400 IDEMPOTENCY_KEY_REQUIRED (batch or ping chunk without the header), 401 UNAUTHENTICATED, 403 ACCOUNT_SUSPENDED, 403 PERMISSION_DENIED, 426 UPGRADE_REQUIRED, 429 RATE_LIMITED, 500 INTERNAL_ERROR, 503 MAINTENANCE.
  • Session expiry while offline: a delegate session lasts 30 days and slides; when a batch answers 401, the app keeps its outbox untouched, asks the delegate to sign in again and resumes the replay with the same keys.

Shared shapes of this page

type SyncItemResult = "APPLIED" | "REPLAYED" | "CONFLICT" | "REJECTED" | "RETRY";

interface SyncItem {
  idempotencyKey: Uuid;                 // the key of this action — identical to the direct call's key
  method: "POST" | "PUT" | "PATCH" | "DELETE";
  path: string;                         // relative to /v1, ids filled in: "/delegate/visits/0193…/arrive"
  body: Record<string, unknown> | null; // exactly the body of the direct call
  occurredAt: Iso;                      // device time of the action
  dependsOn?: Uuid[];                   // keys of items that must have succeeded first
}

interface SyncItemOutcome {
  idempotencyKey: Uuid;
  result: SyncItemResult;
  status: number;                       // HTTP status the direct call returned (or would have)
  body?: unknown;                       // success body (APPLIED, REPLAYED)
  error?: { code: string; message: string; details?: Record<string, unknown> };   // CONFLICT, REJECTED, RETRY
  replayed?: boolean;                   // true when the outcome comes from an earlier execution
  receivedAt: Iso;
}
ValueUI label (fr)Meaning
APPLIEDSynchronisé
REPLAYEDDéjà synchroniséSame Idempotency-Key seen before; original result returned.
CONFLICTConflitState changed on the server; the client must show it in the sync centre.
REJECTEDRejetéValidation or permission error; never retried automatically.
RETRYÀ réessayerTransient server error; retried with backoff.

POST /sync/batch

sync.batch · Access session, delegate scope (each item is authorised like its direct call) · Apps DA · Idempotency-Key required (one key per batch content) · Offline-capable this is the replay channel of the outbox · Release V1 · Requirements SYN-03, SYN-05

Purpose. Replay up to 50 queued offline mutations in order and get one result per item.

Request. Header Idempotency-Key: <uuid> for the batch itself — retrying the exact same batch (same items) reuses it and gets the whole stored response back; any change in the items needs a new batch key. Body:

interface SyncBatchRequest {
  items: SyncItem[];                    // 1–50, oldest first; total body ≤ 1 MB
}

Batchable operations — the offline-capable delegate mutations, and only them:

Everything else is refused per item with REJECTED / NOT_BATCHABLE: reads, delegate.tours.close (needs everything synced first), assisted orders (prices and stock must be confirmed live), delegate.invitations.create (server-generated code), file uploads (binary, through files.createUpload) and location pings (their own route below).

Response 200 OK — always 200 when the batch itself is well-formed, whatever the item results:

interface SyncBatchResponse {
  results: SyncItemOutcome[];           // same order and length as items
  serverTime: Iso;
}

Execution rules

  1. Order. Items run sequentially in the order sent. Each runs the direct operation in its own database transaction — an item never sees a half-applied previous item, and a failed item rolls back alone.
  2. Idempotency per item. The item's idempotencyKey is looked up in IdempotencyRecord (scope = the caller) with the fingerprint of method + path + body:
    • unknown key → executed; its outcome is stored under the key (2xx and 4xx; 5xx outcomes are not stored, so the key can be retried);
    • known key, same fingerprint, stored success → not executed, result REPLAYED with the stored status and body;
    • known key, same fingerprint, stored refusal → not executed, the stored outcome is returned with its result class (CONFLICT or REJECTED) and replayed: true;
    • known key, different fingerprint → REJECTED, 409 IDEMPOTENCY_KEY_REUSED;
    • same key still running (a direct call in flight) → RETRY, 409 IDEMPOTENCY_IN_PROGRESS.
  3. occurredAt. The server injects the item's occurredAt into the operation input when the body has none; a body value different from the item value → REJECTED / VALIDATION_FAILED. Plausibility (more than 5 minutes ahead, older than 72 hours, before the previous step of the same record) → REJECTED / 422 OCCURRED_AT_OUT_OF_RANGE. Both occurredAt and receivedAt are stored.
  4. Dependencies. dependsOn lists keys of items earlier in the same batch or already sent in a previous request. Before running an item, the server checks each dependency's outcome: APPLIED or REPLAYED → run; CONFLICT or REJECTED → the item is not run and gets REJECTED, status 424, code DEPENDENCY_FAILED (details: { dependsOn, dependencyResult }); RETRY → not run, RETRY with code DEPENDENCY_PENDING; a key the server has never seen → REJECTED / DEPENDENCY_FAILED with dependencyResult: "UNKNOWN". A dependency on a later item of the batch makes the whole request 400 VALIDATION_FAILED.
  5. Result classes — from the outcome of the direct operation:
Outcome of the operationResultClient action
2xx first executionAPPLIEDMark the item synced, merge body into the local record (server numbers, times, verdicts)
Stored success replayedREPLAYEDSame as APPLIED — the previous attempt had worked, only its response was lost
409 state conflicts (VISIT_LOCKED, VERSION_CONFLICT, PHONE_ALREADY_EXISTS, TOUR_NOT_IN_PROGRESS, VISIT_IN_PROGRESS, *_TRANSITION_NOT_ALLOWED, RESELLER_TRANSITION_NOT_ALLOWED, TOUR_ORDER_IMPOSED, CLIENT_ID_CONFLICT, VISIT_ALREADY_PLANNED…) and 403 OUT_OF_SCOPE, 403 NOT_ASSIGNED_DELEGATECONFLICTPull sync.delta, show the item in the sync centre with the server state; the delegate decides (re-do, adapt, discard). Never retried automatically
400, 404, 422 (validation, business rule, plausibility), 424 dependencyREJECTEDShow in the sync centre as « en erreur » with the message; never retried automatically; a fix is a new action with a new key
5xx, 429, IDEMPOTENCY_IN_PROGRESS, dependency pending, time budget exceededRETRYKeep in the outbox, retry with the same key with exponential backoff (2 s, 4 s, 8 s … capped at 5 min)
  1. Time budget. The server stops starting new items after 20 seconds; the remaining items come back RETRY with code BATCH_TIME_BUDGET and are simply sent again.
  2. Events of each applied item are emitted exactly as for a direct call (SSE to the back-office, notifications, CRM automation); a replay emits nothing.

Errors of the whole request

StatusCodeWhen
400VALIDATION_FAILEDMalformed item, duplicate idempotencyKey inside the batch, dependsOn pointing to a later item
409IDEMPOTENCY_KEY_REUSED · IDEMPOTENCY_IN_PROGRESSBatch key reused with other items · the same batch is still being processed
413BATCH_TOO_LARGEMore than 50 items or a body over 1 MB (details: { maxItems: 50, maxBytes: 1048576 }) — split it

Worked example — one visit replayed after a network gap

Thursday 5 November, tour TRN-2026-000052. At 11:02 (10:02Z) Yacine taps « Arrivé » at Bazar du Centre — Centre-ville. The app sends the check-in at once over a weak link: the server applies it at 10:02:41Z, but the response never reaches the phone, so the item stays in the outbox. The shop is in a basement arcade with no signal: the checklist (10:15Z), the report (10:31Z) and « Terminer la visite » (10:36Z) are recorded locally; the app chains them with dependsOn (each visit action depends on the previous queued action of the same visit). Meanwhile, at 10:20Z, the commercial administrator cancels the rest of Yacine's tour from the back-office (« Réunion commerciale urgente à l'agence »); the Bazar du Centre visit was checked in but not started on the server, so it becomes CANCELLED — locked (lifecycles). At 11:15Z the network is back and the outbox sends:

{
  "items": [
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e01",
      "method": "POST",
      "path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/arrive",
      "body": {
        "occurredAt": "2026-11-05T10:02:00Z",
        "location": { "lat": 35.69881, "lng": -0.63571, "accuracy": 14, "capturedAt": "2026-11-05T10:01:55Z" },
        "isMockLocation": false
      },
      "occurredAt": "2026-11-05T10:02:00Z"
    },
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02",
      "method": "PUT",
      "path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/checklist",
      "body": {
        "items": [
          { "key": "PRESENT_ACCESSORIES", "checked": true, "checkedAt": "2026-11-05T10:09:00Z" },
          { "key": "SHOW_NEW_ARRIVALS", "checked": true, "checkedAt": "2026-11-05T10:12:00Z" },
          { "key": "PRESENT_PROMOTIONS", "checked": true, "checkedAt": "2026-11-05T10:15:00Z" }
        ],
        "occurredAt": "2026-11-05T10:15:00Z"
      },
      "occurredAt": "2026-11-05T10:15:00Z",
      "dependsOn": ["6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e01"]
    },
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03",
      "method": "PUT",
      "path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/report",
      "body": {
        "id": "0193b10a-5c6d-7e8f-9a01-2b3c4d5e6f03",
        "personMetName": "Nadir Benamar",
        "personMetRoleCode": "OWNER",
        "interestLevel": "HIGH",
        "productsPresented": [{ "categoryId": "0192a002-0000-7000-8000-00000000c031", "brandId": "0192a001-0000-7000-8000-00000000b001" }],
        "promotionsPresented": ["01939f11-5e6f-7a80-9b91-0c1d2e3f4a61"],
        "issueTagCodes": ["EARPHONES_DEMAND"],
        "needs": "Écouteurs Bluetooth d'entrée de gamme pour la clientèle étudiante.",
        "nextAction": "PHONE_FOLLOW_UP",
        "followUpAt": "2026-11-09T09:00:00Z",
        "comment": "Prêt à passer une première commande après l'installation de l'application.",
        "attachmentFileIds": [],
        "occurredAt": "2026-11-05T10:31:00Z"
      },
      "occurredAt": "2026-11-05T10:31:00Z",
      "dependsOn": ["6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02"]
    },
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e04",
      "method": "POST",
      "path": "/delegate/visits/0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98/end",
      "body": {
        "occurredAt": "2026-11-05T10:36:00Z",
        "location": { "lat": 35.69879, "lng": -0.63566, "accuracy": 22, "capturedAt": "2026-11-05T10:35:52Z" }
      },
      "occurredAt": "2026-11-05T10:36:00Z",
      "dependsOn": ["6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03"]
    }
  ]
}

Response 200 OK:

{
  "results": [
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e01",
      "result": "REPLAYED",
      "status": 200,
      "body": {
        "visitId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98",
        "status": "CHECKED_IN",
        "checkedInAt": "2026-11-05T10:02:00Z",
        "receivedAt": "2026-11-05T10:02:41Z",
        "location": { "lat": 35.69881, "lng": -0.63571, "accuracy": 14, "capturedAt": "2026-11-05T10:01:55Z" },
        "distanceMeters": 21,
        "toleranceMeters": 150,
        "accuracy": 14,
        "maxAccuracyMeters": 100,
        "gpsValidated": true,
        "gpsCheck": "WITHIN_TOLERANCE",
        "justification": null,
        "posLocationCandidate": false
      },
      "replayed": true,
      "receivedAt": "2026-11-05T11:15:03Z"
    },
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02",
      "result": "CONFLICT",
      "status": 409,
      "error": {
        "code": "VISIT_LOCKED",
        "message": "Cette visite a été annulée par le back-office (tournée annulée) : vos modifications n'ont pas été enregistrées.",
        "details": {
          "visitId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98",
          "status": "CANCELLED",
          "cause": "TOUR_CANCELLED",
          "lockedAt": "2026-11-05T10:20:00Z",
          "lockedBy": { "id": "0192f0a1-7c3e-7b10-9a4e-5d2c1b0a9e10", "displayName": "Nadia Ferhat" },
          "reason": "Réunion commerciale urgente à l'agence"
        }
      },
      "receivedAt": "2026-11-05T11:15:03Z"
    },
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03",
      "result": "REJECTED",
      "status": 424,
      "error": {
        "code": "DEPENDENCY_FAILED",
        "message": "Non appliqué : une action précédente de cette visite n'a pas pu être synchronisée.",
        "details": { "dependsOn": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e02", "dependencyResult": "CONFLICT" }
      },
      "receivedAt": "2026-11-05T11:15:03Z"
    },
    {
      "idempotencyKey": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e04",
      "result": "REJECTED",
      "status": 424,
      "error": {
        "code": "DEPENDENCY_FAILED",
        "message": "Non appliqué : une action précédente de cette visite n'a pas pu être synchronisée.",
        "details": { "dependsOn": "6f0c2a4e-1b3d-4c5e-9f70-8a1b2c3d4e03", "dependencyResult": "REJECTED" }
      },
      "receivedAt": "2026-11-05T11:15:03Z"
    }
  ],
  "serverTime": "2026-11-05T11:15:03Z"
}

Client handling of each result

ItemResultWhat the app does
1 — arriveREPLAYEDTreats it as applied: removes it from the outbox and stores the server's check-in (distance 21 m, gpsValidated). The visit was checked in once; nothing is duplicated.
2 — checklistCONFLICT (VISIT_LOCKED, cause TOUR_CANCELLED)Pulls sync.delta: the tour and the visit come back CANCELLED. Marks the visit « Conflit » in the sync centre with the server's message, who and when (Nadia Ferhat, 11:20) and keeps the local checklist, report and note readable. Never retried.
3 — reportREJECTED (DEPENDENCY_FAILED)Grouped under the conflict of item 2 — one decision for the whole visit. Not retried. No follow-up task was created on the server.
4 — endREJECTED (DEPENDENCY_FAILED)Same group; the visit is not counted as done.
Group resolution—The sync centre offers « Conserver comme note » — the app sends one new delegate.resellers.addNote on Bazar du Centre with a summary of the checklist and the report (new key, new client id; the store is still in scope), and the delegate can create the follow-up by hand — or « Abandonner ». Either way the four items leave the outbox and the local visit takes the server state (CANCELLED).

GET /sync/delta

sync.delta · Access session, delegate scope · Apps DA · Idempotency-Key — · Offline-capable the pull side of sync: called on app start, after each batch, on a sync hint (silent push or notification.created on the live stream), on pull-to-refresh and every 15 minutes in the foreground · Release V1 · Requirements SYN-01, SYN-04

Purpose. Return everything that changed in the delegate's offline scopes since a cursor — back-office edits of tours and stops, reassignments, tasks, reference lists, announcements — plus tombstones for what left the scope (cahier §7.1).

Request. Query parameters:

NameTypeRule
scopescomma listAny of tours, visits, resellers, tasks, referenceLists, announcements; default all
cursorstringOpaque, from the previous response or the today snapshot (deltaCursor); absent = initial load (current state of every scope)
limitintegerRecords per page across scopes, default 500, max 1 000

What each scope covers

ScopeRecordsShape
toursThe caller's tours dated from today − 7 days to today + 30 daysTourView without visits
visitsThe visits of those toursVisitStop plus tourId
resellersEvery point of sale in the delegate scopeResellerCard
tasksTasks assigned to the caller: OPEN, plus those closed during the last 7 daysTaskView
referenceListsReference items (BUSINESS_TYPE, PERSON_ROLE, VISIT_NOT_DONE_REASON, VISIT_ISSUE_TAG, TOUR_SUSPEND_REASON), visit types and checklist templatesreferenceItems, visitTypes, checklistTemplates
announcementsAnnouncements targeted to the caller and not expiredas announcements.list

Response 200 OK

interface SyncDeltaResponse {
  changes: {
    tours: Array<Omit<TourView, "visits">>;
    visits: Array<VisitStop & { tourId: Uuid }>;
    resellers: ResellerCard[];
    tasks: TaskView[];
    referenceItems: Array<{ listKey: string; code: string; label: string; active: boolean; requiresComment: boolean; sortOrder: number }>;
    visitTypes: VisitTypeView[];
    checklistTemplates: ChecklistTemplate[];
    announcements: Array<{ id: Uuid; title: string; body: string; publishedAt: Iso; expiresAt: Iso | null; read: boolean }>;
  };
  tombstones: Array<{
    entity: "TOUR" | "VISIT" | "RESELLER" | "TASK" | "REFERENCE_ITEM" | "VISIT_TYPE" | "CHECKLIST_TEMPLATE" | "ANNOUNCEMENT";
    id: Uuid;
    reason: "DELETED" | "OUT_OF_SCOPE";
    at: Iso;
  }>;
  nextCursor: string;
  hasMore: boolean;                     // true → call again at once with nextCursor
  fullResyncRequired: boolean;          // true → drop the cached scopes (never the outbox) and reload without cursor
  serverTime: Iso;
}

Example — the pull right after the batch above (GET /v1/sync/delta?cursor=c3luYzoxODQ0Njc0NDA3MzcwOTU2MDIx):

{
  "changes": {
    "tours": [
      {
        "id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
        "number": "TRN-2026-000052",
        "date": "2026-11-05",
        "status": "CANCELLED",
        "orderingMode": "RECOMMENDED",
        "plannedStartAt": "2026-11-05T07:30:00Z",
        "plannedEndAt": "2026-11-05T16:30:00Z",
        "startedAt": "2026-11-05T07:58:00Z",
        "suspendedAt": null,
        "suspendReasonCode": null,
        "closedAt": null,
        "startPoint": { "label": "Agence HOCO Oran", "location": { "lat": 35.6935, "lng": -0.6255 } },
        "instructions": null,
        "counters": { "planned": 3, "unplanned": 1, "done": 1, "notDone": 0, "remaining": 0, "late": 0 },
        "allowedActions": [],
        "closingReport": null,
        "version": 9,
        "updatedAt": "2026-11-05T10:20:00Z"
      }
    ],
    "visits": [
      {
        "id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e98",
        "tourId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
        "number": "VIS-2026-001298",
        "sequence": 2,
        "plannedAt": "2026-11-05T09:30:00Z",
        "slotEndAt": "2026-11-05T10:30:00Z",
        "priority": "HIGH",
        "status": "CANCELLED",
        "isLate": false,
        "isUnplanned": false,
        "visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002",
        "reseller": {
          "id": "0192d1aa-3b4c-7d5e-8f60-718293a4b301",
          "number": "REV-000301",
          "storeName": "Bazar du Centre",
          "ownerName": "Nadir Benamar",
          "phone": "+213770318452",
          "wilayaCode": "31",
          "communeName": "Oran",
          "status": "INTERESTED",
          "location": { "lat": 35.6987, "lng": -0.6359 },
          "addressLine": "Centre-ville, 8 boulevard Emir Abdelkader",
          "landmark": null,
          "whatsapp": null,
          "businessTypeCode": "BAZAAR",
          "potential": "MEDIUM",
          "customerType": null,
          "hasAccount": false,
          "assignedToMe": false,
          "facadeThumbnailUrl": null,
          "lastVisitAt": "2026-11-03T11:40:00Z"
        },
        "distanceFromPreviousMeters": 694,
        "checkedInAt": "2026-11-05T10:02:00Z",
        "startedAt": null,
        "endedAt": null,
        "gpsValidated": true,
        "locked": true,
        "unlockedUntil": null,
        "version": 4
      },
      {
        "id": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e97",
        "tourId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
        "number": "VIS-2026-001297",
        "sequence": 3,
        "plannedAt": "2026-11-05T11:00:00Z",
        "slotEndAt": "2026-11-05T12:00:00Z",
        "priority": "NORMAL",
        "status": "CANCELLED",
        "isLate": false,
        "isUnplanned": false,
        "visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f001",
        "reseller": {
          "id": "0192d1aa-3b4c-7d5e-8f60-718293a4b402",
          "number": "REV-000402",
          "storeName": "Super Marché Zitoune",
          "ownerName": "Hocine Zitouni",
          "phone": "+213698220417",
          "wilayaCode": "31",
          "communeName": "Aïn El Beïda",
          "status": "ACTIVE",
          "location": { "lat": 35.664, "lng": -0.589 },
          "addressLine": "Cité 600 logements, local n° 2",
          "landmark": null,
          "whatsapp": "+213698220417",
          "businessTypeCode": "SUPERMARKET",
          "potential": "MEDIUM",
          "customerType": "DEMI_GROS",
          "hasAccount": true,
          "assignedToMe": true,
          "facadeThumbnailUrl": null,
          "lastVisitAt": "2026-10-29T09:10:00Z"
        },
        "distanceFromPreviousMeters": 5730,
        "checkedInAt": null,
        "startedAt": null,
        "endedAt": null,
        "gpsValidated": null,
        "locked": true,
        "unlockedUntil": null,
        "version": 3
      },
      {
        "id": "0193b0f7-2c3d-7e4f-8a51-6b7c8d9e0f05",
        "tourId": "0193b0e2-4a5b-7c6d-8e7f-9a0b1c2d3e01",
        "number": "VIS-2026-001305",
        "sequence": 4,
        "plannedAt": null,
        "slotEndAt": null,
        "priority": "NORMAL",
        "status": "CANCELLED",
        "isLate": false,
        "isUnplanned": true,
        "visitTypeId": "0192c3d0-1111-7a00-8c00-00000000f002",
        "reseller": {
          "id": "0193a31c-8e2f-7a40-b1c2-d3e4f5a6b342",
          "number": "REV-000342",
          "storeName": "Phone Center Nour",
          "ownerName": "Mohamed Benali",
          "phone": "+213558123456",
          "wilayaCode": "31",
          "communeName": "Bir El Djir",
          "status": "TO_CONTACT",
          "location": { "lat": 35.7071, "lng": -0.5802 },
          "addressLine": "Hai Sabah, rue des Frères Benaouda",
          "landmark": null,
          "whatsapp": "+213558123456",
          "businessTypeCode": "PHONE_SHOP",
          "potential": "MEDIUM",
          "customerType": "DETAIL",
          "hasAccount": false,
          "assignedToMe": true,
          "facadeThumbnailUrl": null,
          "lastVisitAt": null
        },
        "distanceFromPreviousMeters": 4858,
        "checkedInAt": null,
        "startedAt": null,
        "endedAt": null,
        "gpsValidated": null,
        "locked": true,
        "unlockedUntil": null,
        "version": 2
      }
    ],
    "resellers": [],
    "tasks": [],
    "referenceItems": [],
    "visitTypes": [],
    "checklistTemplates": [],
    "announcements": [
      { "id": "0193b0e9-7a8b-7c9d-8e0f-1a2b3c4d5e61", "title": "Réunion commerciale à 12h", "body": "Tous les délégués d'Oran sont attendus à l'agence à 12h00.", "publishedAt": "2026-11-05T10:18:00Z", "expiresAt": "2026-11-05T22:59:59Z", "read": false }
    ]
  },
  "tombstones": [
    { "entity": "RESELLER", "id": "0193a31d-1a2b-7c3d-9e4f-5a6b7c8d9e43", "reason": "OUT_OF_SCOPE", "at": "2026-11-05T08:30:00Z" }
  ],
  "nextCursor": "c3luYzoxODQ0Njc0NDA3MzcwOTU2MTg4",
  "hasMore": false,
  "fullResyncRequired": false,
  "serverTime": "2026-11-05T11:15:05Z"
}

Field notes. The tombstone is Taxiphone Es Salam (created by Yacine on 3 November), reassigned that morning to the delegate of its commune (BR-48): the app removes it from its cache and from any local list; queued actions on it will come back CONFLICT / OUT_OF_SCOPE. Visits keep their sequence after a cancellation.

Errors — 400 VALIDATION_FAILED (unknown scope, limit out of range, cursor malformed or with an invalid signature).

Rules & side effects

  1. Cursor. Opaque, signed by the server (SYNC_CURSOR_SECRET), it encodes the position in the server's monotonic change sequence for each scope, the date anchor of the tour window and a digest of the caller's scope membership (zones, assignment). Changes are returned oldest first, each record once in its latest state (intermediate states are collapsed). The cursor of the today snapshot is taken in the same database snapshot as its payload.
  2. Tombstones. OUT_OF_SCOPE when a record leaves the scope — reseller reassigned, tour reassigned or out of the date window, task reassigned; DELETED for the rare hard deletions (a reference item is deactivated, never deleted — it comes back with active: false).
  3. fullResyncRequired when the cursor is older than 7 days, when the delegate's zones changed (delegate.zones_changed: the membership digest no longer matches) or after a contract change of the delta format. The app then reloads every scope without cursor but keeps its outbox and replays it afterwards; items that no longer fit come back as conflicts.
  4. Paging. hasMore = true until the backlog is drained; the app loops without delay and applies each page in one local transaction.
  5. Precedence on the device. A record pulled by the delta never overwrites a local change still in the outbox: the app keeps its pending local state on top of the server state until the item is acknowledged (APPLIED/REPLAYED) or resolved (conflict decision).
  6. Read-only; no event. Sync hints (silent pushes) are sent by the server when the back-office changes something in a delegate's scope (tour.updated, task.assigned, announcement.published…) so the app pulls within seconds when online.

Used by the sync engine of the delegate app (hoco_offline, MOB-09) — visible on DA-27 and behind every screen working from the local database (DA-05, DA-06, DA-13, DA-19, DA-23).

POST /delegate/location-pings

sync.locationPings · Access session, delegate scope (the caller's tour) · Apps DA · Idempotency-Key required (one per chunk) · Offline-capable yes — samples are stored on the device and uploaded in chunks of up to 200, oldest first, as soon as a connection exists; not part of sync.batch · Release V1 · Requirements FLD-13, FLD-15

Purpose. Upload the positions sampled during the tour: they feed the back-office live map (BO-23, cahier §4.5 « Suivi … des positions ») and the kilometres of the closing report (BR-54).

Request. Body:

interface LocationPingsRequest {
  tourId: Uuid;
  pings: Array<{
    lat: number;
    lng: number;
    accuracy: number;                   // metres
    speed?: number | null;              // m/s as reported by the OS
    heading?: number | null;            // degrees 0–359
    batteryPct?: number | null;
    isMock?: boolean;                   // Android mock-location flag
    recordedAt: Iso;                    // device sampling time (stored as LocationPing.occurredAt)
  }>;                                   // 1–200, any order
}
{
  "tourId": "0193a2f0-5b6c-7e8d-9f01-23456789ab01",
  "pings": [
    { "lat": 35.6936, "lng": -0.6254, "accuracy": 11, "speed": 0.0, "heading": null, "batteryPct": 92, "isMock": false, "recordedAt": "2026-11-03T07:41:05Z" },
    { "lat": 35.699, "lng": -0.606, "accuracy": 9, "speed": 9.7, "heading": 71, "batteryPct": 92, "isMock": false, "recordedAt": "2026-11-03T07:46:05Z" },
    { "lat": 35.705, "lng": -0.588, "accuracy": 13, "speed": 11.2, "heading": 64, "batteryPct": 91, "isMock": false, "recordedAt": "2026-11-03T07:51:05Z" },
    { "lat": 35.7101, "lng": -0.5741, "accuracy": 8, "speed": 4.1, "heading": 58, "batteryPct": 91, "isMock": false, "recordedAt": "2026-11-03T07:56:05Z" }
  ]
}

Response 200 OK

{
  "accepted": 4,
  "duplicates": 0,
  "dropped": [],
  "policy": { "sampling": true, "intervalSeconds": 300, "distanceMeters": 200, "maxBatchSize": 200 },
  "serverTime": "2026-11-03T07:56:09Z"
}

dropped lists refused samples as { "index": 2, "reason": "OUTSIDE_TOUR_INTERVAL" } — reasons OUTSIDE_TOUR_INTERVAL (sampled before the start, during a suspension or after the close) and OCCURRED_AT_OUT_OF_RANGE (more than 5 minutes ahead or older than 72 hours). policy is the current sampling policy: the app applies it at once (a supervisor may change field.locationIntervalSeconds during the day) and stops sampling when sampling is false.

Errors

StatusCodeWhen
404NOT_FOUNDNot a tour of the caller
403LOCATION_CONSENT_REQUIREDThe delegate has no current location consent (BR-53, DA-04)
409TOUR_NOT_IN_PROGRESSThe tour is not IN_PROGRESS or SUSPENDED (details.status) — PLANNED, TO_START, COMPLETED or CANCELLED: nothing is stored
413BATCH_TOO_LARGEMore than 200 pings (details.maxItems)

Rules & side effects

  1. Only during the tour (BR-52): the app samples only while the tour is IN_PROGRESS, every field.locationIntervalSeconds (300) or field.locationDistanceMeters (200), whichever comes first, with the Android foreground-service notice « Localisation active pendant la tournée ». The server accepts a chunk while the tour is IN_PROGRESS or SUSPENDED (so samples taken before a pause can still arrive) and stores only samples whose recordedAt falls inside an in-progress interval of the tour. With field.closingRequiresSync = true the app flushes its samples before closing; samples sent after the close are refused, so the kilometres of the closing report are final.
  2. Idempotent: rows are unique on (delegate, recordedAt); a replayed chunk inserts nothing and reports duplicates. Mocked positions are stored with their flag and shown on BO-23, never used to reject a ping (BR-55 applies to check-ins).
  3. Live map: the newest accepted sample is published as SSE delegate.position on /admin/events (at most one per delegate every 30 s). Positions are never notified to anyone.
  4. Retention: samples older than field.locationRetentionDays (90 days) are purged nightly (BR-84); the tour keeps its computed kilometres.

Used by the location service of the delegate app (MDA-12); the policy is shown to the delegate on DA-26 and consented on DA-04.

On this page