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.
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
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.
The app loads the ceremony
The app appends embedded=true&event_delivery=redirect to the URL and loads it as the WebView's page.
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.
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
}
}
]
}
customauthentication makes SignatureAPI return the ceremony URL instead of emailing it. Put references to your own login session indata; they appear in the audit log.delivery_type: "none"stops SignatureAPI from emailing the signed deliverable to the signer. Set it toemailif you want them to get a copy.redirect_delay: 0is 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-levelWebViewdoesn’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..."
}
}
]
}
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 carrieserror_typeanderror_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:
onShouldStartLoadWithRequestruns before each navigation. Returningfalsecancels thesignatureapi-message://navigation. It is the counterpart ofdecidePolicyForon iOS andshouldOverrideUrlLoadingon 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 handssignatureapi-message://…toLinking.openURLbefore 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 onerror_typeand treat an unknown value as a generic failure. The ceremony already shows the signer a translated message.error_messageis 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.comandfonts.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.
onContentProcessDidTerminatereloads 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.