SDKQuick Start

Scanly SDK quick start

1. Install

Install the SDK and its public contracts/runtime package:

pnpm add @scanly/sdk @scanly/public

@scanly/sdk already declares @scanly/public as a dependency. Declare it in your application as well when importing its types or platform-neutral functions directly.

Install optional peers only for the integrations you use:

# Managed React web forms
pnpm add react react-dom react-hook-form
 
# Browser Firebase push
pnpm add firebase
 
# React Native forms and push
pnpm add react react-hook-form react-native \
  @react-native-firebase/app @react-native-firebase/messaging

2. Create one client

Use the API base URL and public SDK key from the SDK target configured in the Scanly admin panel:

import { createScanlyClient } from '@scanly/sdk';
 
export const scanly = createScanlyClient({
  apiBaseUrl: 'https://api.example.com',
  publicKey: 'sdk_pub_REPLACE_WITH_PUBLIC_KEY',
  session: {
    platform: 'web',
    appIdentifier: 'customer-portal',
  },
  onError(error) {
    console.error(error.code, error.message);
  },
});
 
await scanly.initialize();

The public key identifies the SDK target and is not a secret. Configure allowed origins, capabilities, and attestation on that target. Do not place private API tokens in an SDK application.

initialize() restores local state and creates an installation identity. A protected repository operation creates or refreshes the anonymous SDK session when needed.

3. Use repositories

Fetch a theme

const theme = await scanly.themes.getById('theme-id');

For React, scope that theme to descendant content:

import { ThemeWrapper } from '@scanly/sdk/theme/react';
 
export function ThemedArea({ theme, children }) {
  return <ThemeWrapper theme={theme}>{children}</ThemeWrapper>;
}

Load and submit a lead form

import type { LeadFormValues } from '@scanly/public';
 
const definition = await scanly.leadForms.getDefinition(
  'form_1_REPLACE_WITH_FORM_ID',
  { locale: 'de' }
);
 
const values: LeadFormValues = {
  email: 'person@example.com',
};
 
const attempt = scanly.leadForms.prepare({
  definition,
  values,
  selectedListTargetIds: [],
  grantedConsentIds: ['consent_1_REPLACE_IF_REQUIRED'],
});
 
const receipt = await scanly.leadForms.submit(attempt);

selectedListTargetIds contains optional audience-list targets selected by the visitor. The mandatory definition.rootListTargetId is included automatically. Use the public target IDs from definition.listTargets; database and admin-panel list IDs are not valid submission targets.

Always retry the same prepared attempt after a retryable failure; creating a new attempt also creates a new idempotency key.

4. Render a managed React form

Import the default stylesheet once, for example in the application entry file:

import '@scanly/sdk/lead-forms/styles.css';

Then render the SDK-backed component:

import {
  LEAD_FORM_UI_MESSAGE_KEYS,
  SdkLeadForm,
} from '@scanly/sdk/lead-forms/web';
import { t } from './i18n';
import { scanly } from './scanly';
 
const messageCatalog = {
  ui: {
    loading: t(LEAD_FORM_UI_MESSAGE_KEYS.loading),
    load_error: t(LEAD_FORM_UI_MESSAGE_KEYS.load_error),
    submit: t(LEAD_FORM_UI_MESSAGE_KEYS.submit),
    submitting: t(LEAD_FORM_UI_MESSAGE_KEYS.submitting),
    retry: t(LEAD_FORM_UI_MESSAGE_KEYS.retry),
    success: t(LEAD_FORM_UI_MESSAGE_KEYS.success),
    start_over: t(LEAD_FORM_UI_MESSAGE_KEYS.start_over),
  },
};
 
export function ContactForm() {
  return (
    <SdkLeadForm
      client={scanly}
      formId="form_1_REPLACE_WITH_FORM_ID"
      locale="de"
      messageCatalog={messageCatalog}
      onSubmitted={(receipt) => {
        console.log(receipt.submissionId);
      }}
      onError={(error) => {
        console.error(error);
      }}
    />
  );
}

The SDK exports stable UI and issue message-key maps but no translations. Resolve the entries needed by your form through the host application’s i18n library and pass the resulting text through messageCatalog. Missing entries render their stable key, making incomplete client translations visible.

Use WebLeadForm with a supplied definition for a host-owned transport. Use useLeadForm from @scanly/sdk/lead-forms/react-hook-form for fully custom markup, or createLeadFormController from @scanly/sdk/lead-forms outside React.

Render host-owned authoring data

For an unsaved preview or another host-owned data source, map the source into the public authoring types and let the SDK construct the canonical definition:

import { createLocalLeadFormDefinitionFromAuthoring } from '@scanly/sdk/lead-forms/core';
import { WebLeadForm } from '@scanly/sdk/lead-forms/web';
 
const { definition } = createLocalLeadFormDefinitionFromAuthoring({
  sourceId: 'contact-form',
  authoring: {
    display: { title: 'Contact', resolvedLocale: 'en' },
    fields: [
      {
        sourceId: 'email-field',
        key: 'email',
        type: 'email',
        label: 'Email',
        required: true,
      },
    ],
    listTargets: [
      {
        sourceId: 'newsletter-list',
        label: 'Also subscribe to the newsletter',
      },
    ],
    rootListTargetSourceId: 'contact-list',
    actions: { submit: true },
  },
});
 
export function ContactPreview() {
  return <WebLeadForm definition={definition} mode="preview" />;
}

The source IDs are stable host identities used to derive opaque local IDs. Keep loading, authorization, and persistence in the host; the SDK owns normalization, definition validation, rendering, and form behavior.

Enable address autocomplete

Add the places:read capability to the SDK target. An SdkLeadForm automatically enables first-party Places autocomplete when the authored field contains autocomplete.provider: 'places'; the Google API key remains on the Scanly backend.

For a custom field, reuse the same provider:

const places = scanly.leadForms.createPlacesAutocompleteProvider({
  language: 'de',
});
 
const suggestions = await places.search({
  query: 'Musterstraße 1, Berlin',
  field,
});
 
const providerValues = await places.resolve({
  suggestionId: suggestions[0].id,
  field,
});

Use mapLeadFormAutocompleteValues(field, providerValues) from @scanly/sdk/lead-forms/react-hook-form to apply authored target mappings in a fully custom form.

5. Configure storage deliberately

The default MemoryStorage is cleared on reload. Browser persistence is opt-in:

import { createBrowserStorage, createScanlyClient } from '@scanly/sdk';
 
const client = createScanlyClient({
  apiBaseUrl,
  publicKey,
  storage: createBrowserStorage(window.localStorage),
});

Persistent browser sessions are accessible to JavaScript and therefore to XSS. Prefer memory storage unless reload persistence is required. On native platforms, adapt encrypted storage with createSecureStorage.

6. Register push notifications

Browser Firebase

import { createFirebaseWebAdapter } from '@scanly/sdk/firebase/web';
 
const adapter = createFirebaseWebAdapter({
  app: firebaseApp,
  vapidKey,
  serviceWorkerRegistration,
});
 
const registration = await scanly.push.register({
  adapter,
  consent: {
    state: 'granted',
    policyVersion: 'push-consent-v1',
    clientRecordedAt: new Date().toISOString(),
  },
});

Subscribe to foreground messages:

const unsubscribe = scanly.push.onMessage((message) => {
  console.log(message.title, message.data);
});
 
unsubscribe();

Background delivery originates from Firebase Cloud Messaging and must be registered in the service worker or native application entry point using the platform-specific SDK helper.

Registration is also the client-side interface to Scanly broadcasts. It links the current installation to an SDK target, which administrators can select in the existing push-broadcast editor. When that broadcast is sent, the backend snapshots the target’s enabled, consented registrations and delivers the message through Firebase; the SDK exposes it through the foreground listener above or the platform-specific background handler. Client applications receive broadcasts through this interface but cannot create or send them.

Lead attribution and broadcast filters

Push registration does not require a lead. After a successful SDK lead-form submission, the backend associates that installation with the resulting lead. Push registrations made before or after that submission use the same association. Use the same client and persisted installation identity for both operations; clearing SDK storage creates a new, initially anonymous installation. Existing installations are not retroactively matched by email or device token: they need a new successful SDK form submission. A later submission for a different lead replaces the installation’s association. This is audience attribution, not authentication or permission to read that lead’s private data.

In Studio’s broadcast editor:

  • Eigene App-Ziele selects the SDK apps; Plattformen limits their devices.
  • A Kampagnenfilter includes devices whose associated lead has an active, non-anonymized registration in any selected campaign (OR). Anonymous installations do not match a campaign filter.
  • A campaign-scoped broadcast uses its campaign; a list-based broadcast also restricts membership to that list.
  • An organization-wide broadcast without campaign/list restrictions includes eligible anonymous installations too. A device is captured only once per app, even if its lead matches several campaigns or lists.

Eligibility requires enabled push permission/consent, a non-revoked installation active within the last 90 days, and an active target with a verified Firebase connection. Recipients are captured when the broadcast is queued. Reassigning an installation to another lead invalidates its previously captured delivery binding.

Under the organization’s Gäste tab, open a lead profile to see its associated devices and push-registration/revocation activity. The Push senden action queues a visible notification for that guest’s currently eligible devices only. “Reachable” means eligible to attempt delivery, not a guarantee that the OS will display a notification. The timeline derives push activity from current device records; it is not an immutable history of previous device owners.

7. Handle errors and clean up

import { ScanlyError } from '@scanly/sdk';
 
try {
  await scanly.leadForms.getDefinition(formId);
} catch (error) {
  if (error instanceof ScanlyError) {
    console.error(error.code, error.retryable, error.retryAfterMs);
  }
}
 
await scanly.destroy({ revokeRemoteSession: true });

Call destroy() when the host application disposes the client or changes SDK targets. See the API reference for every supported entry point, function, and type.