Artovnia marketplace - hub integracji e-commerce, medusa.js v2
Strona główna/Blog/Jak zbudowałem provider-neutral hub integracji dla marketplace'u na Medusa.js

Jak zbudowałem provider-neutral hub integracji dla marketplace'u na Medusa.js

Arkadiusz Wawrzyniak

Arkadiusz Wawrzyniak

26 czerwca 2026 - 99 wyświetlenia - 10 min czytania

Udostępnij

Sprzedawca rękodzieła nie pyta, czy Twój marketplace ma lepszy UX od Shopify. Pyta: a co z moimi 300 produktami, które już tam mam?

Twórcy rękodzieła nie zawsze zaczynają od zera. Często mają już sklep na Shopify, klientów w grupie na Facebooku albo całe lata historii sprzedaży na Etsy. Marketplace bez pozycji na rynku nie daje im żadnego oczywistego powodu, żeby się przenosić — to dodatkowe konto, dodatkowy katalog do pilnowania, dodatkowa praca bez gwarantowanego zwrotu.

Artovnia — marketplace sztuki i rękodzieła, budowany bootstrapowo, bez budżetu marketingowego — musiała znaleźć inną drogę, tymbardziej, że ma stać się największą i najlepszą platformą dla sztuki i rękodzieła w Polsce. Nie "jak przekonać sprzedawcę do migracji", lecz "jak zsynchronizować się z tym, co sprzedawca już ma".

A na horyzoncie jest też TikTok Shop. Na tę konkurencję lepiej odpowiedzieć integracją, niż czekać, aż przejmie sprzedawców.

Dlaczego hub, a nie integracja

Najprostsza ścieżka to integracja z jedną platformą, "a reszta dojdzie później". Znam ten plan. I wiem, co oznacza "dojdzie później" — gdy przychodzi czas na WooCommerce, okazuje się, że logika Shopify jest wbita w rdzeń na dziesięć sposobów, a każdy z nich to tydzień refaktoryzacji.

Zamiast tego, od pierwszego modelu w bazie, powstał hub integracji — provider-neutralny z założenia, a nie jako efekt późniejszego sprzątania. Sprzedawcy siedzą na różnych platformach. WooCommerce, TikTok Shop, PrestaShop, Etsy — każde z nich dojdzie z czasem. Hardkodowanie logiki jednej platformy w rdzeniu oznaczałoby przepisywanie fundamentów przy każdej kolejnej integracji.

Pierwszym działającym adapterem został Shopify. W trakcie prac nad publikacją tego artykułu ruszyła implementacja WooCommerce — i stała się pierwszym realnym testem, czy "provider-neutral" nie jest tylko ładnie brzmiącym hasłem.

Architektura huba

Całość układa się w prostą hierarchię: sprzedawca łączy się z hubem przez panel vendora, hub trzyma stan w neutralnych modelach, a konkretny provider tylko wykonuje operacje.

Bash
Seller
  │
  ▼
Artovnia Vendor Panel
  │
  ▼
External Commerce Hub
  │
  ├── IntegrationConnection
  ├── IntegrationEntityMapping
  ├── IntegrationLocationMapping
  ├── IntegrationSyncRun
  ├── IntegrationSyncItem
  ├── IntegrationWebhookEvent
  └── IntegrationConflict
  │
  ├── Shopify Provider
  ├── WooCommerce Provider
  ├── TikTok Shop Provider
  ├── PrestaShop Provider
  └── Future Providers

Wszystko nad linią providerów jest wspólne dla każdej integracji. Wszystko pod nią — wymienne.

1 / 1
Artovnia hub of e-commerce integrations

Kliknij, aby powiększyć

Model domenowy — serce systemu

Najważniejszą decyzją w całym module nie był wybór Shopify jako pierwszego providera. Był nim zestaw modeli, który nie wie, że Shopify istnieje.

  • IntegrationConnection — połączenie sprzedawcy z konkretnym kontem/sklepem u danego providera
  • IntegrationEntityMapping — mapowanie produktu, wariantu, inventory itemu między providerem a Artovnią
  • IntegrationLocationMapping — mapowanie lokalizacji magazynowej providera na stock location w Artovnii
  • IntegrationSyncRun — nagłówek jednego przebiegu synchronizacji, z licznikami sukcesów, porażek i konfliktów
  • IntegrationSyncItem — pojedyncza jednostka pracy w ramach przebiegu, ze statusem, liczbą prób i kluczem idempotencji
  • IntegrationWebhookEvent — trwały inbox webhooków, z deduplikacją po identyfikatorze providera
  • IntegrationConflict — różnica między wartością providera a wartością Artovnii, czekająca na decyzję sprzedawcy

W kodzie te modele schodzą się w jednym serwisie. Zwróć uwagę na granicę: Shopify nie jest częścią serca systemu. Jest tylko wykonawcą.

typescript
class ExternalCommerceModuleService extends MedusaService({    
  IntegrationConnection,    
  IntegrationComplianceProfile,    
  IntegrationSecret,    
  IntegrationEntityMapping,    
  IntegrationCategoryMapping,    
  IntegrationLocationMapping,    
  IntegrationSyncRun,    
  IntegrationSyncItem,    
  IntegrationWebhookEvent,    
  IntegrationConflict,    
}) {    
  // connection lifecycle, encrypted secrets,    
  // sync runs/items, webhook inbox, conflicts    
}

Provider registry zamiast hardkodowanej listy

Hub musi wiedzieć, jakie integracje są dostępne — ale panel vendora nie powinien mieć na sztywno zakodowanej listy "Shopify, WooCommerce, ...". Stąd registry providerów:

typescript
listProviderManifests() {    
  return providerManifests    
}  
  
getProviderManifest(provider: string) {    
  return getProviderManifest(provider)    
}

Hub nie pyta "czy to Shopify?" — sprawdza manifest: jakie ma capability, jaką nazwę pokazać w panelu, jakie typy autoryzacji obsługuje. Panel vendora może wyświetlać dostępne integracje bez żadnego hardkodowania w UI.

Co ważne: WooCommerce jest już obecny w registry z własnym manifestem capabilities, choć sam adapter nadal jest w trakcie implementacji. Provider registry nie jest listą gotowych integracji. Jest kontraktem.

JSON
{
  "provider": "woocommerce",
  "capabilities": [
    "catalog.read",
    "catalog.webhooks",
    "inventory.read",
    "inventory.webhooks",
    "orders.read"
  ],
  "production_ready": false
}

Provider-specific side effects

Jedna z rzeczy, które zmieniły się po zakończeniu Shopify: rejestracja webhooków.

Początkowo logika znajdowała się w neutralnych endpointach połączeń. Gdy pojawił się drugi provider, została przeniesiona do endpointów provider-specific. Dzięki temu hub nie musi znać szczegółów rejestracji webhooków Shopify ani WooCommerce. Hub odpowiada za workflow, synchronizację, bezpieczeństwo i idempotencję. Szczegóły integracyjne zostają po stronie adaptera.

Kolejka, nie wywołanie API

Każda operacja — webhook, import, korekta stocku po zamówieniu — przechodzi przez tę samą ścieżkę:

Bash
Shopify Webhook
      │
      ▼
IntegrationWebhookEvent
      │
      ▼
IntegrationSyncRun
      │
      ▼
IntegrationSyncItem
      │
      ▼
Provider Executor
      │
      ▼
Shopify Adapter
      │
      ▼
Medusa Inventory / Product / Conflict

Ten wzorzec nie powstał dla samej elegancji. Każdy element wynikał z konkretnego wymagania:

  • Webhook Shopify musi dostać odpowiedź 200 szybko — stąd asynchroniczność.
  • Webhooki przychodzą dwa razy — stąd inbox z deduplikacją.
  • Coś może się nie udać tymczasowo — stąd licznik prób i dead-letter.
  • Trzeba wiedzieć, co się stało z każdą operacją — stąd status i diagnostyka na każdym sync itemie.

Klasyfikacja webhooków początkowo znała wyłącznie format Shopify (products/update, orders/paid). Gdy weszło WooCommerce, stała się provider-aware i obsługuje zarówno slash topics Shopify, jak i dot topics (product.updated, order.created) WooCommerce. Workflow tworzenia sync itemów pozostał wspólny, mimo że źródłowe systemy mówią różnymi dialektami.

W executorze dispatch sprowadza się do prostego rozróżnienia po providerze (uproszczona ilustracja):

typescript
// steps/execute.ts — uproszczone  
if (connection.provider === IntegrationProviders.SHOPIFY) {  
  result = await executeShopifySyncItem(context)  
} else if (connection.provider === IntegrationProviders.WOOCOMMERCE) {  
  result = await executeWooCommerceSyncItem(context)  
} else {  
  result = {  
    status: 'skipped',  
    code: 'unsupported_provider',  
  }  
}

A w samym adapterze Shopify to rozróżnienie schodzi na poziom konkretnej operacji:

typescript
export const executeShopifySyncItem = async (    
  context: ShopifyAdapterContext    
): Promise<ShopifySyncItemExecutionResult> => {    
  const operation = String(context.syncItem.operation || '')  
  
  if (operation === 'webhook.products/delete') {    
    return await handleProductDeleteWebhook(context)    
  }  
  
  if (operation === 'webhook.products/create' || operation === 'webhook.products/update') {    
    return await handleProductWebhook(context)    
  }  
  
  if (    
    operation === 'webhook.inventory_levels/update' ||    
    operation === 'webhook.inventory_items/update'    
  ) {    
    return await handleInventoryWebhook(context)    
  }  
  
  if (operation === 'webhook.orders/paid') {    
    return await handleOrderPaidWebhook(context)    
  }  
  
  if (operation === 'order_placed.inventory_delta') {    
    return await handleOutboundOrderInventoryDelta(context)    
  }  
  
  return {    
    status: 'skipped',    
    code: 'unsupported_shopify_operation',    
    message: `Unsupported Shopify sync operation: ${operation}`,    
  }    
}

Gdy Shopify był jedynym providerem, dispatch kończył się na jednym adapterze. Teraz jest osobna gałąź dla WooCommerce. Na razie adapter WooCommerce zwraca woocommerce_operation_not_implemented, ale boundary między providerami już istnieje i czeka.

Najciekawsze było to, że pierwsza weryfikacja tej decyzji przyszła szybciej, niż zakładałem. Zacząłem implementować WooCommerce jeszcze przed publikacją tego artykułu. Modele się nie zmieniły. Workflowy się nie zmieniły. Webhook inbox się nie zmienił. Pojawił się nowy folder providera, własny executor i własne webhook handling. To był pierwszy realny test. I hub go zdał.

Idempotencja nie jako slogan

Synchronizacja, która nie jest idempotentna, jest ładnym schematem do momentu pierwszego zduplikowanego webhooka. W bazie wygląda to następująco — unikalny indeks na klucz idempotencji, scope'owany per połączenie:

sql
"idempotency_key" TEXT NOT NULL,  
CREATE UNIQUE INDEX "UNQ_integration_sync_item_idempotency"  
  ON "integration_sync_item" ("connection_id", "idempotency_key");

A zapis webhooka w serwisie najpierw sprawdza, czy event już istnieje — a jeśli dwa requesty trafią na ten sam event w tej samej milisekundzie, drugi dostaje z powrotem ten sam rekord, nie błąd:

typescript
const existing = await this.listIntegrationWebhookEvents({    
  provider: data.provider,    
  provider_event_id: data.provider_event_id,    
})  
  
if (existing.length > 0) {    
  return existing[0]    
}  
  
try {    
  return await this.createIntegrationWebhookEvents({    
    ...data,    
    received_at: new Date(),    
    status: IntegrationWebhookStatuses.RECEIVED,    
  })    
} catch (error: any) {    
  const isUniqueViolation =    
    error?.code === '23505' ||    
    String(error?.message || '').includes(    
      'UNQ_integration_webhook_event_provider_event'    
    )  
  
  if (!isUniqueViolation) {    
    throw error    
  }  
  
  const [existingAfterRace] = await this.listIntegrationWebhookEvents({    
    provider: data.provider,    
    provider_event_id: data.provider_event_id,    
  })  
  
  if (existingAfterRace) {    
    return existingAfterRace    
  }  
  
  throw error    
}

Sekrety jako część infrastruktury, nie afterthought

Access tokeny Shopify nie są przechowywane jawnie. Trafiają do IntegrationSecret jako zaszyfrowany payload AES-256-GCM, z wersją klucza (key_version) i datą rotacji (rotated_at):

typescript
private encryptPayload(payload: Record<string, unknown>): string {    
  const key = this.deriveEncryptionKey(this.getEncryptionSecret())    
  const iv = crypto.randomBytes(12)    
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)  
  
  const plainText = JSON.stringify(payload)  
  
  let encrypted = cipher.update(plainText, 'utf8', 'hex')    
  encrypted += cipher.final('hex')  
  
  const authTag = cipher.getAuthTag()  
  
  return `gcm:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`    
}

Klucz szyfrujący pochodzi z dedykowanej zmiennej środowiskowej. Fallback do JWT_SECRET istnieje tylko jako mechanizm awaryjny i loguje wtedy ostrzeżenie. To nie jest efektowna część integracji — ale bez niej provider hub byłby zabawką, nie elementem produkcyjnej infrastruktury.

Samonaprawiające się mapowania

Typowa integracja, gdy nie znajdzie mapowania dla przychodzącego identyfikatora, rzuca błąd i kończy. To wystarczy dla jednego sklepu, ale w marketplace'ie — gdzie import produktu i pierwsze webhooki mogą się nakładać w czasie — brakujące mapowanie nie powinno być od razu porażką:

typescript
async function repairInventoryMappingFromVariant(...) {    
  // 1. Find variant mapping from inventory mapping metadata    
  // 2. Load Artovnia variant through query graph    
  // 3. Fallback to product mapping    
  // 4. Fallback to SKU/title matching    
  // 5. Resolve internal Medusa inventory item    
  // 6. Update IntegrationEntityMapping as LINKED    
}

Jeśli mapping inventory itemu nie ma jeszcze internal_id, adapter próbuje go odzyskać z mapowania wariantu, produktu, metadanych, SKU lub tytułu. Dopiero gdy wszystkie ścieżki zawiodą, sync item kończy się jako skipped/failed. W praktyce manualny "Odśwież z Shopify" w panelu sprzedawcy naprawia resztę.

Import produktów i wariantów

Sprzedawca łączy sklep przez OAuth, mapuje swoje lokalizacje magazynowe na lokalizacje w Artovnii i uzupełnia domyślne dane GPSR, zanim cokolwiek zaimportuje. Sam import nie tworzy produktu wprost w katalogu — trafia do createProductRequestWorkflow, czyli tej samej ścieżki weryfikacji, którą przechodzi produkt dodany ręcznie. Po drodze obrazy są ściągane z cdn.shopify.com i przesyłane do modułu File Medusy, a ceny przeliczane do PLN.

Mapowane są trzy poziomy, każdy w IntegrationEntityMapping: produkt, wariant i inventory item. Dzięki temu dwóch sprzedawców może mieć w swoich sklepach Shopify identyczny numeryczny ID produktu — bez żadnego konfliktu, bo mapowania są scope'owane po connection_id.

Konflikty kontra automatyczny stock

To rozróżnienie jest bardziej biznesowe niż techniczne, ale ważniejsze od większości decyzji architektonicznych.

Stan magazynowy musi się synchronizować bez pytania o zgodę. Jeśli produkt zostanie sprzedany na Artovnii, a w Shopify go już nie ma — to nie jest drobny bug. To koszmar finansowy i wizerunkowy: klient płaci za coś, czego nie dostanie. Tego nie odkłada się do ręcznej decyzji, nawet na chwilę.

Opis, tytuł, status produktu — inna sprawa. To własność sprzedawcy w katalogu marketplace'u. Pierwszy import nie generuje konfliktów. Konflikt powstaje później, gdy sprzedawca zmieni coś w Shopify po imporcie — panel pokazuje różnicę między wersją Shopify a wersją Artovnii i pozwala kliknąć jeden przycisk: zastosuj zmianę z Shopify albo zostaw wersję z Artovnii. Ręczna decyzja, ale sprowadzona do jednego kliknięcia.

Dwukierunkowa synchronizacja stocku

Inbound, z Shopify do Artovnii, kończy się tą samą operacją niezależnie od tego, czy lokalizacja już ma zapisany poziom stocku:

typescript
const inventoryService = container.resolve(Modules.INVENTORY)  
  
const existingLevels = await inventoryService.listInventoryLevels({    
  inventory_item_id: internalInventoryItemId,    
  location_id: locationMapping.stock_location_id,    
})  
  
if (existingLevels?.[0]) {    
  await inventoryService.updateInventoryLevels([    
    {    
      inventory_item_id: internalInventoryItemId,    
      location_id: locationMapping.stock_location_id,    
      stocked_quantity: nextStockedQuantity,    
    },    
  ])    
} else {    
  await inventoryService.createInventoryLevels([    
    {    
      inventory_item_id: internalInventoryItemId,    
      location_id: locationMapping.stock_location_id,    
      stocked_quantity: nextStockedQuantity,    
    },    
  ])    
}

Outbound, po złożeniu zamówienia w Artovnii, idzie w drugą stronę przez Shopify Admin GraphQL, z idempotency key wyliczanym deterministycznie z sync itemu:

typescript
const adjustmentIdempotencyKey = stableHash({    
  provider: 'shopify',    
  operation: 'order_placed.inventory_delta',    
  sync_item_id: syncItem.id,    
  idempotency_key: syncItem.idempotency_key || null,    
})  
  
const adjustment = await client.adjustInventoryQuantities({    
  reason: 'correction',    
  name: 'available',    
  referenceDocumentUri: `gid://artovnia/ExternalCommerceSyncItem/${syncItem.id}`,    
  idempotencyKey: adjustmentIdempotencyKey,    
  changes,    
})

A klient Shopify przed wysłaniem mutacji normalizuje pole, którego Shopify wymaga, nawet jeśli go nie znamy:

typescript
const normalizedInput = {    
  ...inventoryAdjustmentInput,    
  changes: input.changes.map((change) => ({    
    ...change,    
    changeFromQuantity: change.changeFromQuantity ?? null,    
  })),    
}  
  
const data = await this.graphql(`    
  mutation ShopifyInventoryAdjustQuantities(    
    $input: InventoryAdjustQuantitiesInput!,    
    $idempotencyKey: String!    
  ) {    
    inventoryAdjustQuantities(input: $input) @idempotent(key: $idempotencyKey) {    
      userErrors {    
        field    
        message    
      }    
      inventoryAdjustmentGroup {    
        createdAt    
        reason    
        referenceDocumentUri    
      }    
    }    
  }    
`, {    
  input: normalizedInput,    
  idempotencyKey,    
})

Dzięki @idempotent(key: $idempotencyKey) retry tej mutacji — na przykład po timeout połączenia — nie zduplikuje korekty stocku w Shopify.

1 / 2
Bi direrectional sync

Kliknij, aby powiększyć

Bug: InventoryLevel vs InventoryItem

Webhook inventory_levels/update przychodzi z polem admin_graphql_api_id, które wygląda tak:

Bash
gid://shopify/InventoryLevel/111411429438?inventory_item_id=45067497472062

Na pierwszy rzut oka: identyfikator inventory itema — webhook dotyczy inwentarza. Nie jest. To identyfikator InventoryLevel. Prawdziwy inventory_item_id siedzi w query stringu tego samego GID-a.

Funkcja, która to rozwiązuje:

typescript
export const getInventoryItemExternalIdFromPayload = (    
  payload: Record<string, unknown> | undefined    
) => {    
  const directId = payload?.inventory_item_id  
  
  if (directId !== undefined && directId !== null) {    
    return String(directId)    
  }  
  
  const adminGraphqlId = payload?.admin_graphql_api_id  
  
  if (typeof adminGraphqlId === 'string') {    
    const match = adminGraphqlId.match(/[?&]inventory_item_id=([^&]+)/)  
  
    if (match?.[1]) {    
      return decodeURIComponent(match[1])    
    }    
  }  
  
  const fallbackId = payload?.id  
  
  return fallbackId === undefined || fallbackId === null ? null : String(fallbackId)    
}

Źródłem prawdy jest payload.inventory_item_id. Parsowanie query stringa z admin_graphql_api_id to tylko fallback.

Wzięcie admin_graphql_api_id jako klucza mapowania psuło powiązania bez żadnego błędu w logach — synchronizacja trafiała w złe rekordy albo nic nie znajdowała. Zrozumienie, że Shopify miesza w jednym webhooku dwa różne obiekty, zajęło dłużej niż sama naprawa. Po fakcie: dedykowane testy regresyjne na ten konkretny przypadek.

Status

Synchronizacja działa end-to-end w obie strony. Zamówienie w Shopify zmienia stock w Shopify i, po webhooku, w Artovnii. Zamówienie w Artovnii zmniejsza stock lokalnie i wysyła korektę do Shopify. Oba kierunki kończą się w logach statusem completed.

Dlaczego Medusa.js udowodniła, że jest dobrym wyborem na marketpalce?

To nie jest ogólna "wolność headless". Konkretne mechanizmy frameworka zdecydowały, że system opisany wyżej dało się zbudować w rozsądnym czasie:

System modułów. Każdy moduł Medusa definiuje własne modele i dostaje za darmo serwis CRUD przez MedusaService(...) — widać to dokładnie w ExternalCommerceModuleService powyżej. Bez tego dziewięć modeli oznaczałoby dziewięć razy napisany od zera CRUD.

Workflowy z kompensacją. Workflow w Medusa to nie tylko sekwencja kroków — każdy krok może mieć funkcję kompensującą, czyli logikę rollbacku. W workflowie tworzenia połączenia krok zapisu sekretu i krok tworzenia linku sprzedawca–połączenie mają zdefiniowaną kompensację. Jeśli coś po drodze się wywali, wcześniejsze kroki są bezpiecznie wycofywane.

Links (defineLink). Moduł external-commerce nie ma żadnej bezpośredniej zależności od modułu sprzedawców. Relacja między seller a integration_connection jest zdefiniowana przez defineLink w osobnym pliku. Moduł integracji zostaje czysto izolowany, relację można odpytać przez query graph — bez foreign key na poziomie modelu.

Query graph. Pozwala pobrać dane rozpięte na wielu modułach jednym zapytaniem — na przykład połączenie razem z jego sellerem — bez ręcznego joinowania na poziomie aplikacji.

Inventory module. Stany magazynowe Artovnii prowadzi wbudowany moduł Inventory. Adapter Shopify nie aktualizuje żadnej własnej tabeli stocku — tylko stocked_quantity w konkretnej lokalizacji przez ten moduł. Automatycznie utrzymuje to zgodność ze wszystkim innym, co z Inventory korzysta — checkout, rezerwacje.

To są konkretne powody, dla których kolejny provider powinien kosztować tylko nowy adapter, nie tydzień przepisywania fundamentów.

Zakończenie

W momencie publikacji WooCommerce jest już podłączony do wspólnej architektury i przechodzi przez własną ścieżkę wykonawczą.

Kolejne platformy — TikTok Shop, PrestaShop, Etsy — powinny być już przede wszystkim problemem implementacji adaptera, nie przebudowy huba. To był cel od początku.

Cel jest prosty: jeśli twórca ma już produkty, stany magazynowe i klientów — onboarding do Artovnii powinien wyglądać jak podłączenie konta, nie jak budowanie biznesu od nowa.

Dlatego pierwszym providerem był Shopify i dlatego Shopify jest tylko początkiem rozwoju tego HUB integracji.

FAQ

Najczęściej zadawane pytania

Oznacza to, że podstawowe modele danych, workflowy i inbox webhooków nie wiedzą, z jaką platformą rozmawiają. Dodanie nowego providera, np. WooCommerce, wymaga tylko nowego folderu adaptera — bez żadnych zmian we współdzielonej infrastrukturze.

Shopify to najczęściej używana platforma wśród twórców rękodzieła. Daje Artovnii najszerszy zasięg onboardingowy przy jednoczesnym weryfikowaniu architektury huba przed rozszerzeniem na WooCommerce, TikTok Shop i inne.

Każdy webhook jest przechowywany w inboxie IntegrationWebhookEvent z unikalnym indeksem na identyfikator eventu providera. Wzorzec check-then-create zabezpieczony przed race condition zapewnia, że nawet równoległe requesty dla tego samego eventu zwracają ten sam rekord, nie duplikat.

Stany magazynowe synchronizują się automatycznie bez zgody sprzedawcy — opóźnienie może prowadzić do zamówień na produkty niedostępne w magazynie. Opisy produktów, tytuły i statusy tworzą zamiast tego rekord konfliktu, pozwalając sprzedawcy jednym kliknięciem zdecydować, czy zastosować wersję z platformy, czy zachować wersję Artovnii.

Tokeny są szyfrowane jako payload AES-256-GCM w rekordzie IntegrationSecret, z wersjonowanym kluczem i datą rotacji. Klucz szyfrujący pochodzi z dedykowanej zmiennej środowiskowej, oddzielonej od innych sekretów aplikacji.