Synchronizacja delta

Większość integracji partnerskich przechowuje lokalną kopię lustrzaną zasobów locco (pracownicy, pojazdy, miejsca powstawania kosztów, działy, wpisy, wypłaty, subskrypcje webhooków). Po początkowym zasileniu bazy (backfill), przy każdym endpoincie pojawia się to samo pytanie: jak odpytywać o zmiany od ostatniej synchronizacji bez ponownego odczytywania każdego wiersza?

Odpowiedzią jest parametr zapytania modifiedSince. Jest on obsługiwany przez każdy stronicowany endpoint listy i stanowi podstawowy wspierany sposób na wykonywanie przyrostowej synchronizacji z partnerskim API w wersji v1.

W skrócie

  • Każdy stronicowany endpoint listy akceptuje parametr ?modifiedSince=<ISO 8601 UTC timestamp>.
  • Strony są zwracane w kolejności (updatedAt, id) ASC, gdy parametr modifiedSince jest ustawiony bez jawnego parametru sort. Jest to kolejność, której partnerzy potrzebują do poruszania się w przód po osi czasu zmian bez ponownego odczytywania już przetworzonych wierszy.
  • Rozmiar strony jest ograniczony po stronie serwera do 100. Większe wartości są po cichu zmniejszane do tego limitu, a pole pageSize w odpowiedzi odzwierciedla tę ograniczoną wartość (a nie surowe żądanie), dzięki czemu obliczenia Math.ceil(total / pageSize) pozostają poprawne.
  • Jako cursor służy wartość updatedAt najnowszego wiersza zwróconego w poprzednim udanym odpytaniu. Zapisz go. Przekaż w kolejnym odpytaniu. Powtórz.

Endpointy

Ten sam kontrakt modifiedSince jest obsługiwany dla:

ZasóbEndpoint
PracownicyGET /api/v1/employees
PojazdyGET /api/v1/vehicles
Miejsca powstawania kosztówGET /api/v1/cost-centers
DziałyGET /api/v1/departments
WpisyGET /api/v1/travel-entries
WypłatyGET /api/v1/payouts
Subskrypcje webhookówGET /api/v1/webhooks

Doręczenia webhooków (GET /api/v1/webhooks/{id}/deliveries) celowo NIE obsługują parametru modifiedSince. Doręczenia są zapisywane wyłącznie w trybie dopisywania (append-only) i są praktycznie niezmienne po utrwaleniu, dlatego partnerzy chcący śledzić stan doręczeń powinni zamiast tego odpytywać według identyfikatora (id) lub filtra statusu.

Co zwraca modifiedSince

W przypadku zapytania takiego jak:

http
GET /api/v1/employees?modifiedSince=2026-04-30T12:00:00Z
Authorization: Bearer locco_live_...
X-Company-Id: 9c4e0d70-9d59-4a4a-a7d9-1e5ff6f1e3ec

Serwer zwraca każdy wiersz, którego wartość updatedAt jest równa lub późniejsza niż podany timestamp. Wartość updatedAt jest obliczana po stronie serwera jako wartość editedAt danego wiersza, a w przypadku wierszy, które nigdy nie były edytowane, przyjmuje domyślnie wartość createdAt, dzięki czemu timestamp jest zawsze uzupełniony.

Wiersz utworzony o 2026-04-30T11:55:00Z i nigdy nieedytowany zostanie wykluczony (jego wartość updatedAt wynosząca 11:55:00Z jest wcześniejsza niż cursor ustawiony na 12:00:00Z). Wiersz utworzony w zeszłym roku, ale edytowany dzisiaj, zostanie uwzględniony.

Gwarancja sortowania

Gdy parametr modifiedSince jest ustawiony bez jawnego parametru sort, serwer porządkuje stronę według (updatedAt ASC, id ASC). Ma to kluczowe znaczenie dla poprawności synchronizacji delta:

  • Bez rozstrzygania remisów za pomocą id, dwa wiersze z identyczną wartością updatedAt pojawiałyby się w niedeterministycznej kolejności w różnych żądaniach, a partnerzy przechodzący przez paginację mogliby po cichu pominąć lub powielić wiersze.
  • Sortowanie ASC (najstarsza zmiana jako pierwsza) oznacza, że cursor partnera na końcu strony to najwyższa napotkana wartość updatedAt, co stanowi naturalny punkt wyjścia dla kolejnego zapytania.

W przypadku przekazania jawnego parametru sort=...&sortDir=desc, serwer nadal dołącza id jako kryterium rozstrzygające, aby paginacja pozostała stabilna, jednak kolejność stron nie będzie odpowiednia dla synchronizacji delta. W przypadku synchronizacji należy pozostawić parametr sort nieustawiony.

Ograniczenie paginacji

Każdy endpoint listy ogranicza wartość pageSize do 100 po stronie serwera. Pole pageSize w odpowiedzi odzwierciedla RZECZYWISTĄ ograniczoną wartość, a nie tę, o którą wnioskowano, zatem:

http
GET /api/v1/employees?pageSize=10000

zwraca do 100 elementów ORAZ treść odpowiedzi zawierającą "pageSize": 100. Odpowiedź zawiera również pole totalPages obliczone na podstawie RZECZYWISTEGO, ograniczonego rozmiaru strony, więc najprostszym warunkiem zakończenia pętli jest page >= totalPages. Należy unikać ponownego obliczania stron na podstawie total / pageSize przy użyciu surowej wartości z żądania: spowoduje to obliczenie zbyt małej liczby stron i ciche pominięcie danych.

Parametr page jest analogicznie ograniczany do minimum 1; wartości stron mniejsze lub równe zero są normalizowane. Pole page w odpowiedzi odzwierciedla faktycznie zwróconą stronę.

Pętla odpytywania

Kontrakt jest prosty: odczytaj zapisany cursor, wykonaj z nim zapytanie, przejdź przez każdą stronę i zapisz najwyższą wartość updatedAt z danego przebiegu jako kolejny cursor.

python
import time
import requests
API = "https://api.getlocco.com"
HEADERS = {
"Authorization": "Bearer locco_live_<your-key>",
"X-Company-Id": "<your-company-id>",
}
def poll_employees(cursor: str | None) -> tuple[list[dict], str]:
"""
Przechodzi przez każdą stronę pracowników zmienionych od czasu `cursor`. Zwraca wiersze
oraz nowy cursor (najwyższą wartość updatedAt napotkaną podczas tego przebiegu).
"""
rows = []
page = 1
new_cursor = cursor
while True:
params = {"page": page, "pageSize": 100}
if cursor is not None:
params["modifiedSince"] = cursor
r = requests.get(f"{API}/api/v1/employees", headers=HEADERS, params=params)
r.raise_for_status()
body = r.json()
rows.extend(body["items"])
# Przesuń cursor do najwyższej wartości updatedAt na tej stronie.
for row in body["items"]:
updated_at = row.get("updatedAt") or row.get("createdAt")
if updated_at and (new_cursor is None or updated_at > new_cursor):
new_cursor = updated_at
# Użyj totalPages z odpowiedzi, a NIE wartości obliczonej na podstawie żądania,
# aby pętla zakończyła się poprawnie, nawet jeśli serwer ograniczył pageSize.
if page >= body["totalPages"]:
break
page += 1
return rows, new_cursor
# Pierwsze uruchomienie: cursor=None pobiera wszystko (pełne zasilenie).
rows, cursor = poll_employees(cursor=None)
save_to_local_db(rows)
save_cursor(cursor)
# Stan ustalony: odpytuj co N minut z zapisanym cursorem.
while True:
time.sleep(300) # 5 minut
cursor = load_cursor()
rows, new_cursor = poll_employees(cursor=cursor)
save_to_local_db(rows)
save_cursor(new_cursor)

Kilka subtelności wartych uwagi:

  • Zapisz cursor dopiero po pomyślnym zakończeniu całego odpytywania, w tym po zapisaniu wierszy w lokalnej bazie danych. W przeciwnym razie awaria w trakcie działania spowoduje utratę pobranych, ale nieutrwalonych wierszy, a kolejne odpytywanie nie pobierze ich ponownie.
  • Cursor działa wykluczająco (exclusive) po stronie serwera, a włączająco (inclusive) na Twojej granicy. Wiersz z wartością updatedAt == cursor pojawi się w kolejnym odpytywaniu. Idempotentne operacje typu upsert po Twojej stronie neutralizują ponowny odczyt. Aby uzyskać ścisłe wykluczenie, po pomyślnym uruchomieniu przesuń zapisany cursor o cursor + 1ms.
  • Równoległe edycje są ostatecznie spójne (eventually consistent). Wiersz edytowany w trakcie odpytywania może pojawić się na późniejszej stronie (ponieważ jego wartość updatedAt przesunęła się poza granicę strony). Twoja logika upsert nadpisze wcześniejszy odczyt nowszym. Ręczne rozwiązywanie konfliktów nie jest wymagane.

Początkowe zasilenie

Przy pierwszym uruchomieniu należy przekazać modifiedSince=null (lub pominąć ten parametr). Serwer zwróci wtedy każdy wiersz powiązany z firmą. Przy rozmiarze strony wynoszącym 100 wierszy, firma zatrudniająca 5000 pracowników to 50 stron. Należy dostosować tempo odpytywania do dostępnego limitu zapytań (zobacz Limity zapytań) i zapisać cursor na samym końcu.

W przypadku bardzo dużych początkowych zasileń partnerzy czasami wolą rozpocząć od modifiedSince=2000-01-01T00:00:00Z, aby korzystać z tej samej ścieżki kodu, co przy odpytywaniu w stanie ustalonym. Pod kątem funkcjonalnym jest to tożsame z pominięciem tego parametru.

Łączenie z webhookami

Webhooki (zobacz Webhooki) wysyłają powiadomienia o zdarzeniach w momencie wywołania zdarzenia domenowego. Są one zalecanym sposobem reagowania na zmiany w czasie zbliżonym do rzeczywistego. Działają one jednak na zasadzie najlepszych starań (best-effort): doręczenie może się nie powieść (niedostępność odbiorcy, chwilowy błąd sieci), a po wyczerpaniu polityki ponownych prób zdarzenie zostaje utracone.

Synchronizacja delta stanowi dla nich trwałe zabezpieczenie. Typowy schemat wygląda następująco:

  1. Webhooki dla niskich opóźnień. Reaguj na zdarzenia w ciągu kilku sekund od ich wystąpienia.
  2. Cogodzinna synchronizacja delta jako sieć bezpieczeństwa. Wychwytuj wszystko, co zostało pominięte przez webhooki (nieudane doręczenia, krótka dezaktywacja subskrypcji, przestój po stronie partnera).

Oba te rozwiązania współdzielą ten sam model danych. Payloady webhooków zawierają identyfikatory zasobów, a partnerzy pobierają pełny zasób za pomocą GET /api/v1/{resource}/{id}, aby uzyskać jego aktualny stan. Endpoint pobierania stanowi jedyne źródło prawdy (single source of truth) w obu tych procesach.