Cross-Repository Contracts
Definition
This document explains the contracts between pulse-web and the systems that consume its output: event-receiver, the optifyr settings service, tools-portal-front, autopilot, and older SDK versions that share storage on the same device. Each contract listed here must change on both sides together, or not at all.
Why contracts fail silently
Pulse is a producer. Almost everything it emits is parsed by another system, and none of those systems complain loudly when the shape drifts:
- event-receiver answers HTTP 200 and strips unknown fields. It validates events against definitions stored in tools-portal with Ajv
removeAdditional: 'all', so a field the portal does not know about simply disappears. - tools-portal-front ignores
postMessagemessages it does not recognise. - autopilot reads a missing state field as an empty string and keeps recording.
- replay-receiver drops invalid metadata and still answers HTTP 200.
Unit and e2e tests in pulse-web only prove that pulse-web sends what pulse-web expects. They cannot prove that the other side still understands it. That is why every contract change needs a counterpart check, and why an HTTP 200 proves nothing.
Browsers keep running cached bundles, and integrators keep running pinned npm versions, for a long time after a release. Old and new SDK versions share cookies, localStorage and IndexedDB on the same device. A rename that looks atomic in a merge request is never atomic in production.
Naming conventions on the wire
Wire field names are snake_case (session_id, country_code, frame_id). The one exception is the batch envelope, which is camelCase (config.deviceId, attributeHints). Keep both as they are. Do not "fix" either convention, because the receiving side depends on it.
A change of type (string to number, string to boolean) is a contract change even when the name stays the same.
Events to event-receiver
Events are sent as POST requests to {serverBaseURL}/{app}/{transport}, where transport is fetch, xhr or beacon and serverBaseURL is https://t.picsart.com/events/v2/web. The body is an array of state-header chunks; see Event Pipeline for how they are built.
| Contract | pulse-web side | Other side (event-receiver) |
|---|---|---|
| URL path and the transport suffix (fetch, xhr or beacon) | tracker/src/tracker.configuration.ts, shared/src/services/http/http.service.ts | apps/instance/src/controllers/web.v2.controller.ts; CORS is configured only on the three-segment form |
Body: array of PulseBatchChunk with config, header, events, attributes, attributeHints | common/src/sdks/tracker/models/batch.ts, common/src/sdks/tracker/transport/group-by-context.ts | data/web-v2-request-schema.ts, models/web/web-session.ts |
config.deviceId, config.sessionId, config.tabId storage types (persistent, tab, memory, none) and config.serverCookies (boolean or 'same-origin') | tracker/src/transport/batch-helper.ts (getTrackerBatchConfig) | Controls server cookie setting and server-side session override |
Header fields device_id, session_id, app, market, platform, v, version, debug, user_id, tier_id, infected[], segments, experiments, data | tracker/src/plugins/state.plugin.ts, tracker/src/services/session/session.service.ts, shared/src/services/state.service.ts; segments and trackable experiments via settings/src/services/response-tracker.service.ts and a dataLayer pulse: 'state' push | libs/sessions/src/models/session-header.ts |
Required per-event fields v, version, session_id, country_code, device_id, platform, market, event_type, timestamp (integer milliseconds) | StatePlugin, SessionService, EventGlobalsPipe; the SDK does not set country_code itself | libs/definitions/src/schemas/event-schema-base.ts; the receiver falls back to cf-ipcountry |
Built-in event names and their data fields (navigation, session_start, click, popup, consent_change, experiment_*, analytics_stats, rum, and others) | tracker/src/plugins/* | Must exist as event definitions in tools-portal, or the fields are stripped |
click_source.navigate_label (string) on click and navigation: the pulse-navigate attribute value of a declared click, on the click itself and on the navigation it causes, in the same tab or in the new tab it opens | tracker/src/services/activity.tracker/declared-navigation.utils.ts, tracker/src/plugins/builtin-events/click-event.plugin.ts, tracker/src/services/navigation-event.service.ts | The tools-portal click and navigation definitions need click_source.navigate_label (STRING) before or with the SDK release that sends it; until then the receiver drops it and still answers 200. See Declared Navigation Source |
Legacy renames _experiment_participate, _app_load, tier_id | The SDK sends experiment_participate; tier_id comes from state | libs/events/src/normalizer/provider.ts |
Request header receiver-debug | tracker/src/services/transport/transport.ts | The receiver echoes its ProcessResult |
header.debug === true routes to debug-only processing | StatePlugin (debug key debugger) | event-processor.service.ts |
Unprefixed cookie and response header paa-did | DeviceIdService, HttpService.replaceDeviceId | The receiver sets both; CORS exposes paa-did |
Device id format a.{c,m,s}.{base36 ts}.{uuid} | shared/src/services/uuid.service.ts (browser uses c and m), public/server/src/services/attribute-parser.service.ts (s) | The receiver decodes first_visit_date from segment 3 (attribute-hints.service.ts); the portal validates with ^[\w.-]{4,128}$ |
Session id ${ms}_${deviceId} | tracker/src/services/session/session.service.ts | Portal session-route-id.util.ts; replay data. After a session handoff (below) the session_id embeds another platform's device id, so never derive device_id from session_id |
Settings to optifyr
The settings SDK requests GET {settingsBaseURL}/{app}/{tag}. See Settings Internals for caching and deadlines.
| Contract | pulse-web side |
|---|---|
Path /{app}/{tag} and the query keys did, uid, tid, dltid, ur, is-test, versioncode, exp, debug-exp, ref, cidp, usrc, umed, ucmp, ucnt, attr-* | settings/src/services/settings/settings-fetch.helper.ts; the server SDK builds the same request in public/server/src/services/transport/request-builder.ts |
Response shape {settings, experiments, segments} | settings/src/services/settings/ |
debug-exp={id}:{variant} | settings/src/services/experiment-debug.service.ts |
tools-portal-front
The portal drives two debugging flows through query parameters and postMessage. See Ecosystem for how the debugger works end to end.
| Contract | pulse-web side | tools-portal-front side |
|---|---|---|
Query parameter ?debug-leuid (its value is ignored) | tracker.configuration.ts (debugLiveEventKey), plugins/live-event-debug.plugin.ts | main/debugger/debugger.component.ts |
postMessage of {type: 'pulse/live-debug-device-id', deviceId} to https://picsart.tools through window.opener | tracker/src/services/live-event-debug.service.ts | main/debugger/device-id-handoff.ts, which checks that the origin equals the app.webBaseUrl origin |
Query parameter ?pulse-debug-exp= carrying a JSON array of app, id, name, variant and return URL; the app must equal the SDK app | settings/src/services/experiment-debug.service.ts | main/experiments/experiment-utils.ts (buildDebugURL) |
Debugger stream fields commonEvent.device_id, debug, experiments, segments, v | StatePlugin, SessionService; experiments and segments from ResponseTrackerService through the dataLayer pulse: 'state' push | main/debugger/debugger.interfaces.ts |
autopilot
The loader injects the autopilot session-recording script and shares Pulse state with it. Several types here are hand-copied between the repositories, so check them against the autopilot source, not against memory.
| Contract | pulse-web side | autopilot side |
|---|---|---|
window.pulse.getState() and getState$(), with the fields session_id, device_id, user_id, app, country_code, tab_id, version | common/src/pulse/proxy.ts, shared/src/services/state.service.ts | packages/browser/src/main/services/pulse-provider.service.ts, session-orchestrator.service.ts |
Globals window.autopilot, window.autopilotOptions, the internal object window.autopilot.__ (fields q, c, idu, t, v, b, m, stopRequested) and autopilot.stop() | loader/src/bundles/autopilot.loader.ts | packages/browser/src/main/entrypoint.ts, main.module.ts, globals.d.ts |
Config in __.c: app, mode (standard, iframe or mobile), initialFrameState | AutopilotMode in the loader | BrowserMode in main/configuration.ts |
postMessage of type autopilot/set-frame-id with a frameState of frame_id, session_id, device_id, tab_id, app, version, country_code, user_id, session_start_time | AUTOPILOT_SET_FRAME_ID_TYPE, AutopilotFrameState (hand-copied) | main/models/frame-messaging.ts (FrameStatePayload) |
URL layout /ap/web/v1/sdk/{app}/ap.{variant}.js (variant is modern or compat) and worker apw.{variant}.js; the duplicate-script check looks for ap-worker and ap/web | Loader | autopilot worker |
Modern or compat detection must match autopilot's worker selection, including the type of __.m | browserModernity in the loader | main/globals.d.ts, worker.service.ts |
Remote settings pulse_loading_config and autopilot_config.tracking_enabled | all-in-one.bundle.ts | Authored in the portal |
The type of __.m currently differs between the two sides (the loader sends a string, autopilot expects a boolean). It is a tracked open issue. Fix both sides together.
Session handoff from other Pulse platforms
Another Pulse platform hands its session to the web through the OAuth state query parameter. See Session Handoff for the integrator and producer view, and Identity and State for the adoption rules. The landing URL carries:
state=<nonce>.<base64url(returnPath)>.<base64url("psid=<lastActivityMs>|<sessionId>")>
The producer builds the psid value as `${Date.now()}|${(await pulse.getState()).session_id}`.
| Contract | pulse-web side (reader) | Producer side |
|---|---|---|
Landing query state: 3 or more segments, the psid in the 3rd, later segments ignored, padding up to two =. psid is read through URLSearchParams (a raw pipe or %7C). lastActivityMs has 13 digits; sessionId is <13-digit startMs>_<device part> with startMs ≤ lastActivityMs and a device part of 4–128 [A-Za-z0-9._-] | shared/src/services/session-handoff.service.ts (parse), tracker/src/services/session/session.service.ts (adopt) | The producer's OAuth flow builds the psid from Date.now() and its own session_id. Anything invalid is ignored |
Freshness: adopted on every boot while now − lastActivityMs < sessionTimeoutMS and at most 60 s in the future | SessionService | lastActivityMs comes from the producer's clock and is compared with this device's clock |
- An adoption that changes the stored session id fires
session_startwith nosourceand no renewalnavigation. Re-adopting the same id fires nothing. - A back-forward-cache restore re-checks the psid; a
session_startfired there has no precedingnavigation, so consumers must not require one. - Autopilot reads
pulse.getState()after the tracker boots, so it records the adoptedsession_idfrom the start.
Shared storage, cookie and global names
These names are read by other SDK versions on the same device, by @pulse/server during SSR, and by legacy integrations. See Identity and State for how each value is resolved.
| Name | Where it is defined | Notes |
|---|---|---|
paa_ prefix | storagePrefix | The Worker sets it too. Don't change it |
pulse-state cookie, 16 pipe-separated fields | common/src/context/state.ts, settings/src/services/cookie-sync.service.ts | Read by @pulse/server. The field order is a contract |
paa-did (unprefixed), w2m_meta_info, OptanonConsent, _fbp | UnPrefixedCookieStorageToken | Look these up with unprefixed storage, or you read keys like paa_paa-did |
paa_did, paa_sid, paa_la, paa_ss, paa_tab_id, paa_debug, paa_debug-exp, paa_experiments, paa_config_override | Various services | Persisted across SDK upgrades. A rename orphans existing users' data |
paa_tsp in sessionStorage | TrafficSourceService | A per-tab presence marker ('1'). It lives only as long as the tab, so it is safe to rename or drop; older bundles ignore it |
paa_tab_activity_{tabId} (each tab's recent clicks and context menus), paa_tab_declared_navigation_{tabId}, paa_tab_new_tab_taken_{tabId}; sessionStorage paa_tab_opener, paa_first_navigation | tracker/src/services/activity.synchronizer/activity-synchronizer.ts, tracker/src/services/tabs/tab-opener-detector.ts | Every SDK version on the site reads every tab's paa_tab_activity_* entries to guess a navigation's source and sends an entry's type as click_source.type, so a new entry type reaches the wire through older SDKs. A declared middle-click is stored as type: 'click' with auxClick: true for that reason. See Declared Navigation Source |
IndexedDB pulseDBN version 2 with eventsStore, batchesStore, settingsStore, attributesStore | shared/src/services/indexedDB | A schema change needs an upgrade path |
window.pulse, θ, window.pulseInit, window.pulseReady, window.dataLayer, window.pulse.tracker, window.pulse.settings | proxy.ts, legacy-on-ready.ts | Legacy integrations use these |
For the storage keys as seen by integrators, see the Storage Usage section of the Web SDK docs and Web To Mobile for w2m_meta_info.
Checklist for a contract change
Follow these steps for any change that touches a row on this page.
- Find every consumer. Grep the sibling repositories (event-receiver, tools-portal-front, autopilot, replay-receiver) for the literal name, and note the consuming file.
- Stay backward compatible. Keep the old name or shape working until the new one is deployed everywhere. Keep reading the old storage key, and keep emitting the old field, until every consumer has moved. Cached bundles and old npm versions keep running for a long time.
- Plan the order. Decide whether the counterpart must change first, together, or after, and link its merge request from yours.
- Define new event fields first. A new
datafield on a built-in event must be added as an event definition in tools-portal for that app before release, or event-receiver strips it. - Verify end to end. Check the event in the portal debugger. An HTTP 200 proves nothing.
- Update this page in the same change.
A breaking change to a public npm API (@pulse/browser, @pulse/react, @pulse/core, @pulse/server, @pulse/express, @pulse/nest, @picsart/pulse-common, @picsart/pulse-mini) is also a contract change. It needs a BREAKING CHANGE footer; see Contributing.
Where to look in the code
| Area | pulse-web path |
|---|---|
| Batch envelope and grouping | packages/common/src/sdks/tracker/models/batch.ts, packages/common/src/sdks/tracker/transport/group-by-context.ts |
| Batch config (storage types, server cookies) | packages/browser/tracker/src/transport/batch-helper.ts |
| Header and state fields | packages/browser/tracker/src/plugins/state.plugin.ts, packages/browser/shared/src/services/state.service.ts |
Transport and paa-did handling | packages/browser/shared/src/services/http/http.service.ts |
| Id formats | packages/browser/shared/src/services/uuid.service.ts, packages/browser/tracker/src/services/session/session.service.ts |
| Session handoff | packages/browser/shared/src/services/session-handoff.service.ts, packages/browser/tracker/src/services/session/session.service.ts |
| Settings request | packages/browser/settings/src/services/settings/settings-fetch.helper.ts |
| Portal debug flows | packages/browser/tracker/src/services/live-event-debug.service.ts, packages/browser/settings/src/services/experiment-debug.service.ts |
| Autopilot integration | packages/browser/loader/src/bundles/autopilot.loader.ts |
pulse-state cookie | packages/common/src/context/state.ts, packages/browser/settings/src/services/cookie-sync.service.ts |
| Global proxy | packages/common/src/pulse/proxy.ts |