Platforms

Embedding in a React Native Mobile App

Add embedded signing to React Native apps using react-native-webview

Your React Native app shows the signing ceremony in a react-native-webview WebView and learns how it ended without leaving the app.

Complete, runnable example: the mobile integration demo has a React Native app in its react-native/ folder, a demo server, and end-to-end tests. Every sample on this page comes from it.

The samples are tested with React Native 0.87 (bare React Native with Hermes and the New Architecture) and react-native-webview 14, on iOS and Android.

How it works

sequenceDiagram
    participant App as React Native app
    participant Server as Your server
    participant API as SignatureAPI
    participant View as WebView
    App->>Server: Ask for the signing link
    Server->>API: Create the envelope with custom authentication
    API-->>Server: Ceremony URL
    Server-->>App: Ceremony URL
    App->>View: Load the URL with embedded=true and event_delivery=redirect
    Note over View: The recipient signs
    View->>App: onShouldStartLoadWithRequest sees signatureapi-message://ceremony.completed
    App->>View: Return false to cancel the navigation, then close
    App->>Server: Did the recipient sign?
    Server->>API: Read the envelope
    API-->>Server: Recipient status is completed
    Server-->>App: Confirmed, show the result screen
1

Your server creates the ceremony

Your server creates the envelope with custom authentication. SignatureAPI returns the ceremony URL to your server instead of emailing it. Your server passes the URL to the app.

2

The app loads the ceremony

The app appends embedded=true&event_delivery=redirect to the URL and loads it as the WebView's page.

3

The ceremony reports how it ended

When the ceremony ends, it navigates to a signatureapi-message:// URL. onShouldStartLoadWithRequest cancels that navigation and reads the event from the URL.

4

Your server confirms the outcome

The app asks your server whether the recipient really signed before it shows a result.

The ceremony is the WebView’s top-level page, so the recipient’s ceremony needs no embeddable_in.

Create the ceremony on your server

Create the envelope on your server and give the signer a ceremony with custom authentication:

// POST https://api.signatureapi.com/v1/envelopes
// X-API-Key: key_test_...
// Content-Type: application/json

{
  "title": "Sample agreement",
  "documents": [ /* ... */ ],
  "recipients": [
    {
      "type": "signer",
      "key": "signer",
      "name": "Jane Doe",
      "email": "jane.doe@example.com",
      "delivery_type": "none",
      "ceremony": {
        "authentication": [
          {
            "type": "custom",
            "provider": "YourApp",
            "data": {
              "Session ID": "a4f9e8b2-7c1d-4b2d-9a4b-e0c5d6f7a1b3",
              "Authenticated At": "2025-12-31T23:59:59Z"
            }
          }
        ],
        "redirect_delay": 0
      }
    }
  ]
}
  • custom authentication makes SignatureAPI return the ceremony URL instead of emailing it. Put references to your own login session in data; they appear in the audit log.
  • delivery_type: "none" stops SignatureAPI from emailing the signed deliverable to the signer. Set it to email if you want them to get a copy.
  • redirect_delay: 0 is optional. It hands control back to the app as soon as the signer finishes, so the app can show its own result screen. Without it, the ceremony shows its own result page for 3 seconds. See the ceremony object.
  • Leave out redirect_url. Embedded ceremonies ignore it.
  • Leave out embeddable_in. A top-level WebView doesn’t need it.

When the envelope leaves the processing status, read it and return the signer’s ceremony.url to the app:

// GET https://api.signatureapi.com/v1/envelopes/{envelope_id}
// X-API-Key: key_test_...

// HTTP/1.1 200 OK
{
  "id": "abcdef12-3456-7890-1234-abcdef123456",
  "status": "in_progress",
  "recipients": [
    {
      "type": "signer",
      "key": "signer",
      "ceremony": {
        "url": "https://sign.signatureapi.com/en/start?token=eyJhbGcNiIsInR..."
      }
    }
  ]
}

Never call SignatureAPI from the app. An API key in an app bundle is a leaked key. Your server holds the key, checks that the caller is the signer, and returns only the ceremony URL.

The ceremony URL is a bearer credential: anyone holding it can sign. Don’t log it and don’t store it on the device. To resume later, ask your server for the current URL. Every read of the envelope returns a new URL for the same ceremony.

Build the signing component

Install react-native-webview:

npm install react-native-webview

The component has three parts: a function that builds the URL the WebView loads, a parser for the event URL, and the WebView itself.

Build the ceremony URL

embeddedCeremonyUrl adds embedded=true and event_delivery=redirect to the URL from your server. It works whether or not the URL already has a query string, and replaces earlier values of either parameter:

/**
 * Turns the ceremony URL from the server into the URL the WebView loads.
 *
 * `embedded=true` adapts the ceremony UI; `event_delivery=redirect` makes it
 * report its ending as a `signatureapi-message://` navigation. Existing values
 * of either parameter are replaced, everything else is kept as sent.
 */
export function embeddedCeremonyUrl(ceremonyUrl: string): string {
  const hashIndex = ceremonyUrl.indexOf('#');
  const withoutHash = hashIndex === -1 ? ceremonyUrl : ceremonyUrl.slice(0, hashIndex);
  const hash = hashIndex === -1 ? '' : ceremonyUrl.slice(hashIndex);

  const queryIndex = withoutHash.indexOf('?');
  const base = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
  const kept =
    queryIndex === -1
      ? []
      : withoutHash
          .slice(queryIndex + 1)
          .split('&')
          .filter(pair => pair && !isOverridden(pair));

  return `${base}?${[...kept, 'embedded=true', 'event_delivery=redirect'].join('&')}${hash}`;
}

function isOverridden(pair: string): boolean {
  const name = pair.split('=', 1)[0];
  return name === 'embedded' || name === 'event_delivery';
}

To hide the ceremony’s own Cancel controls, for example because your app has its own Close button, also append allow_cancel=false. See Disable Cancel.

Parse the event

With event_delivery=redirect, the ceremony ends by navigating to a signatureapi-message:// URL:

  • The URL’s host is the event type, such as ceremony.completed.
  • On ceremony.failed, the query carries error_type and error_message, form-encoded (+ is a space).
signatureapi-message://ceremony.failed/?error_type=unauthorized&error_message=The+link+is+no+longer+valid.

This is a client-side navigation, not an HTTP redirect. No request reaches the network, so HTTP-layer interception never sees it. See Event delivery for the full format.

Don’t parse the event URL with new URL(). In bare React Native, the global URL is React Native’s own polyfill, on Hermes as on any engine. Its host only understands http and https URLs, so it returns an empty string for signatureapi-message://ceremony.completed. Its searchParams also cuts a value at a raw = and throws on a malformed escape. Code that switches on new URL(url).host never matches an event.

parseCeremonyEvent reads the URL with a small, explicit parser instead:

/**
 * A terminal event from an embedded ceremony.
 *
 * With `event_delivery=redirect` the ceremony reports how it ended by
 * navigating to `signatureapi-message://<type>/?error_type=…&error_message=…`.
 * Branch on `type` and `errorType` only.
 */
export interface CeremonyEvent {
  type: string;
  errorType: string | null;
  errorMessage: string | null;
}

export const CEREMONY_EVENT_SCHEME = 'signatureapi-message';

export const isCompleted = (event: CeremonyEvent) => event.type === 'ceremony.completed';
export const isCanceled = (event: CeremonyEvent) => event.type === 'ceremony.canceled';

// scheme://authority[path][?query][#fragment]
const EVENT_URL = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)[^?#]*(?:\?([^#]*))?/i;

/**
 * Returns the event a `signatureapi-message://` URL describes, or null for any other URL.
 *
 * Deliberately not `new URL(url)`: in bare React Native the global `URL` is a
 * partial polyfill whose `host` only understands http(s) URLs, so it returns
 * "" for this scheme.
 */
export function parseCeremonyEvent(url: string): CeremonyEvent | null {
  const match = EVENT_URL.exec(url);
  if (!match || match[1]?.toLowerCase() !== CEREMONY_EVENT_SCHEME) return null;
  const type = match[2];
  if (!type) return null;

  const query = parseQuery(match[3] ?? '');
  return {
    type,
    errorType: query.get('error_type') ?? null,
    errorMessage: query.get('error_message') ?? null,
  };
}

/** Form-encoded query → first value per name. */
function parseQuery(query: string): Map<string, string> {
  const values = new Map<string, string>();
  for (const pair of query.split('&')) {
    if (!pair) continue;
    const separator = pair.indexOf('=');
    const name = decodeFormComponent(separator === -1 ? pair : pair.slice(0, separator));
    const value = separator === -1 ? '' : decodeFormComponent(pair.slice(separator + 1));
    if (!values.has(name)) values.set(name, value);
  }
  return values;
}

/**
 * Decodes one form-encoded component: "+" means a space, then percent-decoding.
 * A malformed escape is kept as written rather than dropping the whole event.
 */
function decodeFormComponent(component: string): string {
  const spaced = component.replace(/\+/g, ' ');
  try {
    return decodeURIComponent(spaced);
  } catch {
    return spaced.replace(/(?:%[0-9a-f]{2})+/gi, run => {
      try {
        return decodeURIComponent(run);
      } catch {
        return run;
      }
    });
  }
}

Show the ceremony

CeremonyWebView loads the ceremony as the WebView’s page and passes each event to onEvent. This is an excerpt of the demo’s component, reduced to the onShouldStartLoadWithRequest handler:

import { useCallback, useEffect, useMemo, useRef, type ComponentType, type RefAttributes } from 'react';
import { Linking, type StyleProp, type ViewStyle } from 'react-native';
import { WebView, type WebViewProps } from 'react-native-webview';
import { parseCeremonyEvent, type CeremonyEvent } from './ceremonyEvent';
import { embeddedCeremonyUrl } from './ceremonyLink';

// react-native-webview 14 types its component as `WebView<P = undefined>` with
// props `WebViewProps & P`, which strict null checks reduce to `never`. Pin P.
type WebViewHandle = WebView<object>;
const TypedWebView = WebView as unknown as ComponentType<WebViewProps & RefAttributes<WebViewHandle>>;

type ShouldStartLoad = NonNullable<WebViewProps['onShouldStartLoadWithRequest']>;

/**
 * react-native-webview checks every navigation against this list before any
 * callback runs, and hands a URL that fails it to `Linking.openURL` instead.
 * The default list (http and https) would send `signatureapi-message://` to the
 * OS, so the handler would never see the event. "*" lets every navigation
 * reach the handler; the handler decides.
 */
const ORIGIN_WHITELIST = ['*'];

interface Props {
  ceremonyUrl: string;
  onEvent: (event: CeremonyEvent) => void;
  style?: StyleProp<ViewStyle>;
}

/** Shows a SignatureAPI ceremony as the WebView's page and reports how it ended. */
export function CeremonyWebView({ ceremonyUrl, onEvent, style }: Props) {
  const webView = useRef<WebViewHandle>(null);
  const ended = useRef(false);
  const onEventRef = useRef(onEvent);
  useEffect(() => {
    onEventRef.current = onEvent;
  }, [onEvent]);

  const source = useMemo(() => ({ uri: embeddedCeremonyUrl(ceremonyUrl) }), [ceremonyUrl]);

  const end = useCallback((event: CeremonyEvent) => {
    if (ended.current) return;
    ended.current = true;
    onEventRef.current(event);
  }, []);

  // Android waits at most 250 ms for this answer on its UI thread, then lets
  // the load go ahead. The event is still delivered; only the cancel is lost.
  const onShouldStartLoadWithRequest = useCallback<ShouldStartLoad>(
    request => {
      const event = parseCeremonyEvent(request.url);
      if (!event) return true;
      end(event);
      return false;
    },
    [end],
  );

  return (
    <TypedWebView
      ref={webView}
      source={source}
      originWhitelist={ORIGIN_WHITELIST}
      onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
      // Links that ask for a new window, such as "Powered by SignatureAPI", open in the browser.
      onOpenWindow={({ nativeEvent }) => openExternally(nativeEvent.targetUrl)}
      // iOS may kill the web process under memory pressure, typically while the
      // app is in the background. Reload the same link: it stays valid.
      onContentProcessDidTerminate={() => webView.current?.reload()}
      allowsBackForwardNavigationGestures={false}
      domStorageEnabled={false}
      thirdPartyCookiesEnabled={false}
      webviewDebuggingEnabled={__DEV__}
      style={style}
    />
  );
}

function openExternally(url: string) {
  if (!/^https:\/\//i.test(url)) return;
  Linking.openURL(url).catch(() => {
    // No browser available: stay on the ceremony.
  });
}

Two props decide whether your app sees the event:

  • onShouldStartLoadWithRequest runs before each navigation. Returning false cancels the signatureapi-message:// navigation. It is the counterpart of decidePolicyFor on iOS and shouldOverrideUrlLoading on Android. On Android, the library waits at most 250 ms for the answer. If the JavaScript thread is busy longer, the navigation goes ahead: the event still arrives, but the cancel is lost.
  • originWhitelist={['*']} is required. With the default whitelist (http://*, https://*), the library hands signatureapi-message://… to Linking.openURL before any callback runs, and your app never sees the event.

onNavigationStateChange also receives the event URL, but only after the navigation started, so it can’t cancel it. The demo keeps it as an alternative handler for comparison; use onShouldStartLoadWithRequest.

The demo shows the component full screen in a modal, under a Close button the app owns. Android’s back gesture (onRequestClose) and the Close button count as canceled. Excerpt from the demo’s CeremonyScreen:

<Modal
  visible={ceremony !== null}
  animationType="slide"
  presentationStyle="fullScreen"
  onRequestClose={onClose}
  statusBarTranslucent
  navigationBarTranslucent
>
  {/* A modal is a separate window on Android: it needs its own inset provider. */}
  <SafeAreaProvider>
    <SafeAreaView style={styles.screen} edges={['top', 'left', 'right']}>
      <View style={styles.bar}>
        <Pressable onPress={onClose} testID="close-ceremony" accessibilityRole="button" hitSlop={8} style={styles.close}>
          {({ pressed }) => <Text style={[styles.closeLabel, pressed && styles.pressed]}>Close</Text>}
        </Pressable>
        <Text style={styles.title} accessibilityRole="header">
          Sign document
        </Text>
      </View>
      {shown ? <CeremonyWebView key={shown.url} ceremonyUrl={shown.url} onEvent={onEvent} style={styles.webView} /> : null}
    </SafeAreaView>
  </SafeAreaProvider>
</Modal>

Handle each outcome

Branch on the event type, and on error_type for failures. This excerpt comes from the demo’s SigningFlow, the state machine behind its screens (ready, preparing, signing, confirming, finished):

ceremonyEnded = async (event: CeremonyEvent) => {
  if (this.current.name !== 'signing') return;
  const { envelopeId } = this.current.ceremony;
  if (isCompleted(event)) {
    const run = this.moveTo({ name: 'confirming' });
    this.moveTo({ name: 'finished', ending: await this.confirmSignature(envelopeId) }, run);
  } else if (isCanceled(event)) {
    this.moveTo({ name: 'finished', ending: { kind: 'canceled' } });
  } else {
    this.moveTo({ name: 'finished', ending: { kind: 'couldNotOpen', reason: explanation(event) } });
  }
};

/** The signer left through the app's own Close button or Android's back gesture. */
closeCeremony = () => {
  if (this.current.name === 'signing') this.moveTo({ name: 'finished', ending: { kind: 'canceled' } });
};

/**
 * `ceremony.completed` is a UI signal. The envelope on the server is the
 * proof, and its status can take a moment to catch up.
 */
private async confirmSignature(envelopeId: string): Promise<Ending> {
  try {
    const client = new DemoServerClient(this.loadConfig().demoServerUrl);
    for (let attempt = 0; attempt < CONFIRM_ATTEMPTS; attempt++) {
      if (signerCompleted(await client.envelope(envelopeId))) return { kind: 'signed' };
      await new Promise<void>(resolve => setTimeout(resolve, CONFIRM_INTERVAL_MS));
    }
    return { kind: 'notConfirmed', reason: 'SignatureAPI hasn’t confirmed the signature yet. It usually takes a few seconds.' };
  } catch (error) {
    return { kind: 'notConfirmed', reason: messageOf(error, 'Couldn’t check the signature with the demo server.') };
  }
}
function explanation(event: CeremonyEvent): string {
  switch (event.errorType) {
    case 'unauthorized':
      return 'This signing link is no longer valid. Start again to get a new one.';
    case 'already_completed':
      return 'This document has already been signed.';
    case 'not_available':
      return 'This document is no longer available for signing.';
    default:
      return 'The signing session couldn’t be completed. Start again to get a new link.';
  }
}

DemoServerClient is the demo’s client for your server, never for SignatureAPI. client.envelope(id) calls your server, which reads the envelope from SignatureAPI (GET /v1/envelopes/{id}). signerCompleted is true when the signer’s status is completed.

  • ceremony.completed: the signer finished. Treat it as a UI signal, not proof. Ask your server to read the envelope or recipient before you show “Signed” or act on it. See Confirm the outcome on your server.
  • ceremony.canceled: the signer tapped Cancel. The envelope stays open, and your server can return a URL to resume later.
  • ceremony.declined: an approver tapped Reject. The demo’s signer never sends it, so its flow has no case for it. Your handler should treat it like its other endings: close the WebView and ask your server for the envelope status.
  • ceremony.failed: the ceremony couldn’t be used. Branch on error_type and treat an unknown value as a generic failure. The ceremony already shows the signer a translated message. error_message is a fixed English description for your logs: don’t show it to signers and don’t branch on it. See Error types.
  • Your own Close button or Android’s back gesture: the ceremony sends no event. Treat it as canceled in the app.

Configure the WebView

The default WebView settings work. The ceremony needs JavaScript, which is on by default. It uses no cookies, localStorage, sessionStorage or IndexedDB, so the demo turns DOM storage and third-party cookies off on Android (domStorageEnabled={false}, thirdPartyCookiesEnabled={false}). iOS keeps the WKWebView defaults.

  • Hosts. The WebView contacts sign.signatureapi.com, api.signatureapi.com, vault.signatureapi.com, fonts.googleapis.com and fonts.gstatic.com. Allow them if your app restricts network access. See Browser requirements.
  • Web process termination. iOS can kill the web content process, usually while the app is in the background. onContentProcessDidTerminate reloads the same URL, which stays valid.
  • New windows. Links that ask for a new window open in the system browser through onOpenWindow, so the ceremony stays in place.
  • Back gesture. allowsBackForwardNavigationGestures={false} keeps an iOS swipe from navigating away from the ceremony.
  • Debugging. webviewDebuggingEnabled={__DEV__} turns on web inspection in debug builds only.

Test the integration

Use your test API key on your server to create test envelopes. Test envelopes don’t send emails.

To stop email link scanners from completing ceremonies, the ceremony arms completion only after input a person produces, such as a touch. Drive the ceremony with a test tool that sends real touches through the operating system. The demo uses Maestro, which reads WebView content through the platform accessibility tree and taps with real touches on both iOS and Android. Clicks dispatched from JavaScript don’t count. If a test reaches Finish without real input, the ceremony opens a “Confirm to continue” dialog. Don’t confirm it in tests: it means the test is not acting like a signer. See Automated testing.

Assert on what the app shows after it receives the event. The demo’s sign flow:

appId: com.signatureapi.demo.reactnative
name: Signing the sample document
tags: [sign]
---
- runFlow:
    file: ../subflows/open-ceremony.yaml
    env:
      DEMO_SERVER_URL: ${DEMO_SERVER_URL}
      CEREMONY_HANDLER: ${CEREMONY_HANDLER}
- runFlow:
    file: ../subflows/check-agreement.yaml
    env:
      STATEMENT: By checking
- tapOn: Agree and Continue
- swipe:
    direction: UP
    duration: 600
- swipe:
    direction: DOWN
    duration: 600
- tapOn: Sign here
- runFlow:
    file: ../subflows/check-agreement.yaml
    env:
      STATEMENT: By selecting
- tapOn: Adopt and Sign
- tapOn: Finish
- extendedWaitUntil:
    visible:
      id: result-title
      text: Document signed
    timeout: 60000

The swipes scroll the document like a signer. The app shows “Document signed” only after it intercepts ceremony.completed and your server confirms the signature. Never assert on the ceremony’s console output.

Also test the other endings. The demo’s flows cover the first two:

  • ceremony.canceled: tap Cancel in the ceremony and expect your canceled screen.
  • unauthorized: create a new ceremony for the recipient, then open the earlier URL.
  • already_completed: open the URL again after the signer finished.

Unit-test the event parser with real event URLs, including a form-encoded error_message, a raw = inside a value, and a malformed escape.

Complete example

Mobile integration demo

A runnable server and React Native app in the react-native/ folder, with Maestro flows that sign a real test-mode ceremony on iOS and Android.