Platforms

iOS

Embed the SignatureAPI signing ceremony in an iOS app with WKWebView

Your iOS app shows the signing ceremony in a WKWebView and learns how it ended without leaving the app.

Complete, runnable example: the mobile integration demo has a SwiftUI app, a demo server, and end-to-end tests. Every sample on this page comes from it.

The demo’s iOS app signing a test document inside the app, then showing its own result screen:

The demo's iOS app signing a test document in an embedded ceremony

The samples need iOS 17 or later, the demo’s deployment target.

How it works

sequenceDiagram
    participant App as iOS app
    participant Server as Your server
    participant API as SignatureAPI
    participant View as WKWebView
    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: Navigate to signatureapi-message://ceremony.completed
    App->>View: Cancel the navigation and 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 WKWebView's page.

3

The ceremony reports how it ended

When the ceremony ends, it navigates to a signatureapi-message:// URL. Your navigation delegate 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 WKWebView’s top-level page, so you don’t need embeddable_in or a host page. This page uses event_delivery=redirect, the recommended delivery, which needs no JavaScript bridge either. To receive the event as a JavaScript message instead, see Use message delivery instead.

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.
  • Leave out redirect_url. Embedded ceremonies ignore it.
  • Leave out embeddable_in. A top-level WKWebView 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.

Load the ceremony

CeremonyLink turns the URL from your server into the URL the WebView loads. It adds embedded=true and event_delivery=redirect:

import Foundation

/// Turns the ceremony URL from the server into the URL the WebView loads.
enum CeremonyLink {
    /// `embedded=true` adapts the ceremony UI; `event_delivery=redirect` makes it
    /// report its ending as a `signatureapi-message://` navigation.
    static func embedded(_ ceremonyURL: URL) -> URL {
        guard var components = URLComponents(url: ceremonyURL, resolvingAgainstBaseURL: false) else { return ceremonyURL }
        var items = (components.queryItems ?? []).filter { $0.name != "embedded" && $0.name != "event_delivery" }
        items.append(URLQueryItem(name: "embedded", value: "true"))
        items.append(URLQueryItem(name: "event_delivery", value: "redirect"))
        components.queryItems = items
        return components.url ?? ceremonyURL
    }
}

CeremonyWebView wraps a WKWebView for SwiftUI. It loads the ceremony as the page and passes each event to onEvent:

import SwiftUI
import WebKit

/// Shows a SignatureAPI ceremony as the WKWebView's page and reports how it ended.
struct CeremonyWebView: UIViewRepresentable {
    let ceremonyURL: URL
    let onEvent: (CeremonyEvent) -> Void

    func makeCoordinator() -> Coordinator {
        Coordinator(onEvent: onEvent)
    }

    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
        webView.navigationDelegate = context.coordinator
        webView.allowsBackForwardNavigationGestures = false
        #if DEBUG
        webView.isInspectable = true
        #endif
        context.coordinator.url = CeremonyLink.embedded(ceremonyURL)
        webView.load(URLRequest(url: context.coordinator.url!))
        return webView
    }

    func updateUIView(_ webView: WKWebView, context: Context) {}

    static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
        webView.stopLoading()
    }

    final class Coordinator: NSObject, WKNavigationDelegate {
        private let onEvent: (CeremonyEvent) -> Void
        fileprivate var url: URL?
        private var ended = false

        init(onEvent: @escaping (CeremonyEvent) -> Void) {
            self.onEvent = onEvent
        }

        func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) {
            guard let url = action.request.url else { return decisionHandler(.cancel) }

            if let event = CeremonyEvent(url: url) {
                decisionHandler(.cancel)
                guard !ended else { return }
                ended = true
                onEvent(event)
                return
            }

            // Links that ask for a new window, such as "Powered by SignatureAPI", open in Safari.
            if action.targetFrame == nil {
                decisionHandler(.cancel)
                UIApplication.shared.open(url)
                return
            }

            decisionHandler(.allow)
        }

        func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
            // iOS may kill the web process under memory pressure, typically while the
            // app is in the background. Reload the same link: it stays valid.
            if let url { webView.load(URLRequest(url: url)) }
        }
    }
}

Show it full screen under a bar your app owns:

NavigationStack {
    CeremonyWebView(ceremonyURL: ceremony.url) { event in
        Task { await flow.ceremonyEnded(with: event, envelopeId: ceremony.envelopeId) }
    }
    .ignoresSafeArea(.container, edges: .bottom)
    .navigationTitle("Sign document")
    .navigationBarTitleDisplayMode(.inline)
    .toolbar {
        ToolbarItem(placement: .topBarLeading) {
            Button("Close") { flow.closeCeremony() }
        }
    }
}
.interactiveDismissDisabled()

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.

Receive the result

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 such as URLProtocol or a proxy never sees it. Catch it in webView(_:decidePolicyFor:decisionHandler:), as CeremonyWebView does, and cancel it.

CeremonyEvent parses the URL:

import Foundation

/// 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: `errorMessage` is for logs.
struct CeremonyEvent: Equatable, Sendable {
    static let scheme = "signatureapi-message"

    let type: String
    let errorType: String?
    let errorMessage: String?

    init?(url: URL) {
        guard url.scheme == Self.scheme, let host = url.host(), !host.isEmpty,
              var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        else { return nil }
        // The query is form-encoded: "+" means a space.
        components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%20")
        let items = components.queryItems ?? []
        type = host
        errorType = items.first { $0.name == "error_type" }?.value
        errorMessage = items.first { $0.name == "error_message" }?.value
    }
}

See Event types for every event and Event delivery for the delivery modes.

Handle each outcome

Branch on the event type, and on error_type for failures. This excerpt comes from the demo’s SigningFlow, the observable model behind its screens. phase decides what the app shows:

import Foundation
import Observation

@Observable
final class SigningFlow {
    enum Phase: Equatable {
        case ready
        case preparing
        case signing(Ceremony)
        case confirming
        case finished(Ending)
    }

    struct Ceremony: Identifiable, Equatable {
        let id = UUID()
        let envelopeId: String
        let url: URL
    }

    enum Ending: Equatable {
        case signed
        case canceled
        case declined
        case couldNotOpen(String)
        case notConfirmed(String)
    }

    private(set) var phase = Phase.ready

    private let makeClient: () throws -> DemoServerClient

    // ...

    func ceremonyEnded(with event: CeremonyEvent, envelopeId: String) async {
        switch event.type {
        case "ceremony.completed":
            phase = .confirming
            phase = .finished(await confirmSignature(envelopeId: envelopeId))
        case "ceremony.canceled":
            phase = .finished(.canceled)
        case "ceremony.declined":
            phase = .finished(.declined)
        default:
            phase = .finished(.couldNotOpen(Self.explanation(for: event)))
        }
    }

    /// `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 func confirmSignature(envelopeId: String) async -> Ending {
        do {
            let client = try makeClient()
            for _ in 0..<12 {
                if try await client.envelope(envelopeId).signerCompleted { return .signed }
                try await Task.sleep(for: .seconds(1.5))
            }
            return .notConfirmed("SignatureAPI hasn’t confirmed the signature yet. It usually takes a few seconds.")
        } catch {
            return .notConfirmed(error.localizedDescription)
        }
    }

    private static func explanation(for event: CeremonyEvent) -> String {
        switch event.errorType {
        case "unauthorized": "This signing link is no longer valid. Start again to get a new one."
        case "already_completed": "You already finished this document."
        case "not_available": "This document is no longer available for signing."
        default: "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.
  • 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: the ceremony sends no event. Treat it as canceled in the app.

Use message delivery instead

event_delivery=redirect is the recommended delivery. If you’d rather receive the event as a JavaScript message, keep loading the ceremony as the WKWebView’s page and use event_delivery=message instead. The ceremony is still the top-level page, so you still don’t need embeddable_in.

When the ceremony ends, it calls parent.postMessage(payload, "*"). As the top-level page, parent is the page’s own window, so the page receives its own message: { "type": "ceremony.completed" }, plus error_type and error_message on failure. A script that runs at document start forwards it to Swift.

The bridge script

This script is the security check. It forwards a message only when it comes from the ceremony’s origin and from this very window. Other messages go out as "kind": "rejected", which the app ignores. Copy it unchanged:

(function () {
  var CEREMONY_ORIGIN = "https://sign.signatureapi.com";

  function bridge() {
    var handlers = window.webkit && window.webkit.messageHandlers;
    return (handlers && handlers.signatureapiBridge) || window.signatureapiBridge;
  }

  window.addEventListener("message", function (event) {
    var target = bridge();
    if (!target) return;
    if (event.origin !== CEREMONY_ORIGIN || event.source !== window) {
      target.postMessage(JSON.stringify({ kind: "rejected", payload: { origin: event.origin } }));
      return;
    }
    target.postMessage(JSON.stringify({ kind: "event", payload: event.data }));
  });
})();

It posts a JSON string, { "kind": "event", "payload": … }, to the message handler named signatureapiBridge. The demo’s Android app embeds the same script.

Install it in the WebView

CeremonyEventDelivery names the two modes, and CeremonyLink.embedded takes one instead of always appending redirect:

import Foundation

/// How the ceremony reports its ending to the app: the `event_delivery` parameter.
enum CeremonyEventDelivery: String, Sendable {
    case redirect
    case message
}

enum CeremonyLink {
    static let host = "sign.signatureapi.com"

    static func embedded(_ ceremonyURL: URL, delivery: CeremonyEventDelivery = .redirect) -> URL {
        guard var components = URLComponents(url: ceremonyURL, resolvingAgainstBaseURL: false) else { return ceremonyURL }
        var items = (components.queryItems ?? []).filter { $0.name != "embedded" && $0.name != "event_delivery" }
        items.append(URLQueryItem(name: "embedded", value: "true"))
        items.append(URLQueryItem(name: "event_delivery", value: delivery.rawValue))
        components.queryItems = items
        return components.url ?? ceremonyURL
    }
}

This excerpt shows what message delivery adds to CeremonyWebView. decidePolicyFor and webViewWebContentProcessDidTerminate stay as in Load the ceremony, except that decidePolicyFor now calls end(with:):

import SwiftUI
import WebKit

// Excerpt: the parts of CeremonyWebView that event_delivery=message adds.
struct CeremonyWebView: UIViewRepresentable {
    let ceremonyURL: URL
    var eventDelivery: CeremonyEventDelivery = .redirect
    let onEvent: (CeremonyEvent) -> Void

    /// The `webkit.messageHandlers` name the bridge script posts to.
    static let messageHandlerName = "signatureapiBridge"

    /// The bridge script, byte for byte.
    static let messageBridgeScript = """
        (function () {
          var CEREMONY_ORIGIN = "https://sign.signatureapi.com";

          function bridge() {
            var handlers = window.webkit && window.webkit.messageHandlers;
            return (handlers && handlers.signatureapiBridge) || window.signatureapiBridge;
          }

          window.addEventListener("message", function (event) {
            var target = bridge();
            if (!target) return;
            if (event.origin !== CEREMONY_ORIGIN || event.source !== window) {
              target.postMessage(JSON.stringify({ kind: "rejected", payload: { origin: event.origin } }));
              return;
            }
            target.postMessage(JSON.stringify({ kind: "event", payload: event.data }));
          });
        })();
        """

    func makeCoordinator() -> Coordinator {
        Coordinator(onEvent: onEvent)
    }

    func makeUIView(context: Context) -> WKWebView {
        let configuration = WKWebViewConfiguration()
        if eventDelivery == .message {
            let controller = configuration.userContentController
            controller.addUserScript(WKUserScript(source: Self.messageBridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
            // The controller retains its handlers; the proxy keeps it from retaining the coordinator.
            controller.add(WeakScriptMessageHandler(context.coordinator), name: Self.messageHandlerName)
        }

        let webView = WKWebView(frame: .zero, configuration: configuration)
        webView.navigationDelegate = context.coordinator
        webView.allowsBackForwardNavigationGestures = false
        context.coordinator.url = CeremonyLink.embedded(ceremonyURL, delivery: eventDelivery)
        webView.load(URLRequest(url: context.coordinator.url!))
        return webView
    }

    func updateUIView(_ webView: WKWebView, context: Context) {}

    static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
        webView.stopLoading()
        webView.configuration.userContentController.removeScriptMessageHandler(forName: messageHandlerName)
    }

    final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
        private let onEvent: (CeremonyEvent) -> Void
        fileprivate var url: URL?
        private var ended = false

        init(onEvent: @escaping (CeremonyEvent) -> Void) {
            self.onEvent = onEvent
        }

        // ... decidePolicyFor and webViewWebContentProcessDidTerminate ...

        /// event_delivery=message: what the bridge script forwarded. The script
        /// runs in the main frame only, but every frame can reach the handler, so
        /// check again that the sender is the ceremony's main frame.
        func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
            let origin = message.frameInfo.securityOrigin
            guard message.frameInfo.isMainFrame,
                  origin.protocol == "https", origin.host == CeremonyLink.host, origin.port == 0,
                  let event = CeremonyEvent(messageBody: message.body)
            else { return }
            end(with: event)
        }

        /// Reports the first event only.
        private func end(with event: CeremonyEvent) {
            guard !ended else { return }
            ended = true
            onEvent(event)
        }
    }
}

/// Forwards script messages to a handler it doesn't retain.
private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
    private weak var handler: (any WKScriptMessageHandler)?

    init(_ handler: any WKScriptMessageHandler) {
        self.handler = handler
    }

    func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
        handler?.userContentController(controller, didReceive: message)
    }
}
  • Document start, main frame only. .atDocumentStart installs the listener before the ceremony can post. forMainFrameOnly: true keeps it out of frames inside the page.
  • Check the sender again. Every frame can reach a script message handler, so the handler accepts only the main frame on https://sign.signatureapi.com.
  • Weak handler. The user content controller retains its handlers. WeakScriptMessageHandler keeps it from retaining the coordinator, and dismantleUIView removes the handler when the view goes away.

Parse the forwarded message

The bridge message carries the same type, error_type and error_message as the redirect URL. Add a second initializer to CeremonyEvent, so SigningFlow handles both deliveries with the same code:

import Foundation

// Excerpt: the members of CeremonyEvent that parse a bridge message.
struct CeremonyEvent: Equatable, Sendable {
    /// The events the ceremony emits.
    static let terminalTypes: Set = ["ceremony.completed", "ceremony.canceled", "ceremony.declined", "ceremony.failed"]

    let type: String
    let errorType: String?
    let errorMessage: String?

    // ... init?(url:) as in Receive the result ...

    /// Parses the body the top-level message bridge posts: the JSON string
    /// `{"kind":"event","payload":{"type":…,"error_type":…,"error_message":…}}`.
    ///
    /// Returns nil for `"kind":"rejected"` (a message that failed the origin or
    /// source check) and for any payload that is not a ceremony event: a
    /// top-level page can also receive unrelated messages from itself.
    init?(messageBody: Any) {
        guard let json = messageBody as? String,
              let message = try? JSONDecoder().decode(BridgeMessage.self, from: Data(json.utf8)),
              message.kind == "event", Self.terminalTypes.contains(message.payload.type)
        else { return nil }
        type = message.payload.type
        errorType = message.payload.errorType
        errorMessage = message.payload.errorMessage
    }

    private struct BridgeMessage: Decodable {
        struct Payload: Decodable {
            let type: String
            let errorType: String?
            let errorMessage: String?

            enum CodingKeys: String, CodingKey {
                case type
                case errorType = "error_type"
                case errorMessage = "error_message"
            }
        }

        let kind: String
        let payload: Payload
    }
}

Accept only the ceremony’s event types. The page can also receive unrelated messages from itself, and those pass the origin and source check.

With message, the ceremony doesn’t navigate to signatureapi-message://. Keeping the redirect interception in decidePolicyFor is harmless, so one CeremonyWebView supports both deliveries.

Configure the WebView

The default WKWebViewConfiguration works. The ceremony uses no cookies, localStorage, sessionStorage or IndexedDB, so WebKit’s third-party cookie blocking is fine. It needs JavaScript, which WKWebView enables by default.

  • 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. webViewWebContentProcessDidTerminate reloads the same URL, which stays valid.
  • New windows. Links that ask for a new window (targetFrame == nil) open in Safari, so the ceremony stays in place.
  • Back gesture. allowsBackForwardNavigationGestures = false keeps a swipe from navigating away from the ceremony.
  • Keyboard. .ignoresSafeArea(.container, edges: .bottom) extends the WebView to the bottom edge but still respects the keyboard safe area, so fields stay visible while the signer types.

Load it in an iframe instead

If your web SDK already embeds the ceremony in an <iframe>, the same page can run inside a WKWebView. Use this path only to share that host page with your web app. You don’t need it to receive events as messages: message delivery works with the ceremony as the top-level page. The top-level pattern above is simpler: no host page and no embeddable_in.

  1. Load your host page with WKWebView.loadHTMLString(_:baseURL:) and a synthetic https base URL, such as https://app.example.invalid. Nothing is fetched from it.
  2. List that origin in the ceremony’s embeddable_in when you create it. The ceremony builds its CSP frame-ancestors directive from it.
  3. Use event_delivery=message in the iframe URL, and escape & as &amp; in the src attribute.

The host page accepts a message only when it comes from the ceremony’s origin and from this iframe’s window:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Signing ceremony</title>
<style>
  html, body { margin: 0; height: 100%; background: #ffffff; }
  iframe { display: block; width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<iframe id="ceremony" title="Signing ceremony" src="https://sign.signatureapi.com/en/start?token=eyJhbGcNiIsInR...&amp;embedded=true&amp;event_delivery=message"></iframe>
<script>
  (function () {
    var CEREMONY_ORIGIN = "https://sign.signatureapi.com";
    var frame = document.getElementById("ceremony");

    window.addEventListener("message", function (event) {
      if (event.origin !== CEREMONY_ORIGIN || event.source !== frame.contentWindow) return;
      // Forward event.data to native code.
    });
  })();
</script>
</body>
</html>

The ceremony posts with target origin "*" and its payload carries no secrets, so the origin and source check is yours to make. Forward accepted messages to Swift with window.webkit.messageHandlers.<name>.postMessage, received by a WKScriptMessageHandler you register on the WebView’s user content controller.

Test the integration

To stop email link scanners from completing ceremonies, the ceremony arms completion only after input a person produces. XCUITest taps 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.

Drive the ceremony with real taps and assert on what the app shows after it receives the event:

@MainActor func testSigningTheSampleDocument() throws {
    let app = launch()
    let web = try openCeremony(in: app)

    tapWhenReady(try checkbox(in: web, labelPrefix: "By checking"))
    tapWhenReady(web.buttons["Agree and Continue"])
    tapWhenReady(web.buttons["Sign here"])
    tapWhenReady(try checkbox(in: web, labelPrefix: "By selecting"))
    tapWhenReady(web.buttons["Adopt and Sign"])
    tapWhenReady(web.buttons["Finish"])

    let title = app.staticTexts["result-title"]
    XCTAssertTrue(title.wait(for: \.label, toEqual: "Document signed", timeout: 45), "last result title: \(title.label)")
}

/// Web content animates in; a tap before it settles is lost.
@MainActor private func tapWhenReady(_ element: XCUIElement, timeout: TimeInterval = 20, file: StaticString = #filePath, line: UInt = #line) {
    let ready = element.waitForExistence(timeout: timeout) && element.wait(for: \.isHittable, toEqual: true, timeout: timeout)
    XCTAssertTrue(ready, "\(element) never became tappable", file: file, line: line)
    Thread.sleep(forTimeInterval: 0.4)
    element.tap()
}

launch, openCeremony and checkbox are small helpers in the demo’s UI tests. 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 failures:

  • unauthorized: create a new ceremony for the recipient, then open the earlier URL.
  • already_completed: open the URL again after the signer finished.

Complete example

Mobile integration demo

A runnable server, iOS app and Android app, with UI tests that sign a real test-mode ceremony.