# Embedded signing

Your customers sign directly in your application: you show our signing page in an `iframe` on your own website. This works at every level where external recipients act (SES, AES), and for approvers and viewers. **Qualified signatures (QES) are never embedded**: members with a certificate sign in the dashboard or through the approval link.

This guide is the English version of the German guide `embedded-signing` and covers the same steps.

Your business logic still relies on the **webhook** (or `GET /v1/events`). The messages of the embedded page are hints for your user interface, not a confirmation.

## 1. Set the allowed websites

Only websites on the list of your organization may embed the signing page. The list is empty until you fill it, and empty means embedding is off.

```http
PUT /v1/embed-settings
Authorization: Bearer qgw_live_…
Idempotency-Key: 5d1f…
Content-Type: application/json

{ "allowedOrigins": ["https://app.example.com", "https://*.example.org"] }
```

Response:

```json
{ "object": "embed_settings", "allowedOrigins": ["https://app.example.com", "https://*.example.org"] }
```

Rules for every entry:

- `https://host[:port]` without path, query or credentials, or `https://*.host` for all subdomains of a domain **you own** (not `*.vercel.app`, not `*.co.uk` and similar public suffixes).
- No bare `*`, no IP addresses, no `http://`, except `http://localhost:<port>` with a **test key**; such entries apply in test mode only.
- At most 20 entries, each only once. A rejected entry answers 422 / 9012 with `details.reason` (`origin_invalid`, `origin_duplicate`) and `details.index`.
- Scope `org:manage` to change (off by default), `org:read` to read (`GET /v1/embed-settings`). In the dashboard, owners and administrators change the list.
- Every change appears in the activity log (`EMBED_SETTINGS_UPDATED`).

Changes apply immediately to our signing page and to new embedded sessions. For the signing step of SES/AES, the signature service receives the list when the signing round starts; a later change only affects new rounds. There, only exact origins count (entries with `*` allow only our page, not the signing step), and at most nine of your origins per round.

The same rule applies per mode to the signing page, embedded sessions and the signing step: in live mode only the `https` entries count, and `http://localhost:<port>` and other test-only entries count in test mode only. If embedding is switched off for your organization (tier feature) or still blocked in live mode, the signing step receives none of your origins either. Qualified signatures (QES) are never embedded.

## 2. Request an embedded session

As soon as a recipient is up next (the event `recipient.ready`), your server requests a session for the website on which you show the page:

```http
POST /v1/envelopes/{id}/recipients/{rid}/embedded-session
Authorization: Bearer qgw_test_…
Idempotency-Key: 8c2e…
Content-Type: application/json

{ "origin": "https://app.example.com", "locale": "en" }
```

```json
{
  "object": "embedded_session",
  "url": "https://sign.<domain>/s/<token>?embed=1",
  "expiresAt": "2026-09-30T12:05:00Z",
  "origin": "https://app.example.com"
}
```

- Scope `envelopes:signing_urls` (off by default), rate limit `write`.
- The URL is valid for **5 minutes and exactly once**, and only embedded on **this** origin. On the first load the page exchanges it for a session that it keeps in memory only; a second call shows "This link is not valid" without revealing whether the envelope exists. Request a new session for every display.
- The link in the invitation email stays valid.
- The response is never stored (it contains a link): repeating a call with the same `Idempotency-Key` creates a new session; the key only protects against simultaneous duplicate calls (409 / 9008).
- Treat the URL like a password: do not log it, do not pass it on, and do not create it in your browser application (only on your server).
- Every session appears in the audit trail of the envelope (`LINK_ISSUED`, without the URL).

Possible errors, checked in this order:

| Status / code | Meaning |
|---|---|
| 404 | The envelope or recipient does not belong to your organization or not to the mode of the key |
| 403 / 9039 | Embedding is switched off for your organization |
| 403 / 9038 | `origin` is not on the list (also: a wildcard instead of an exact origin, `http://localhost` in live mode) |
| 422 / 9012 `qes_not_embeddable` | The recipient signs qualified (QES) |
| 409 / 9033 | The recipient is not up next (`details.reason`) |
| 409 / 9030 `signing_page_disabled` | The signing page is switched off |
| 403 / 9030 `embedding_live_disabled` | Live key: embedding is not yet released for live use |
| 403 | The key lacks `envelopes:signing_urls` |

A signing link (`POST …/signing-url`) or the link of the invitation email can be embedded as well: append `?embed=1`. Then every website on your list may show the page, and the link works as usual (15 minutes until first opened, then a 60-minute session). The embedded session is preferable because it is bound to exactly one website.

## 3. Show the page with the widget

```html
<div id="signature"></div>
<script src="https://sign.<domain>/embed/v1/onesign.js"></script>
<script>
  const widget = OneSign.mountSigning(document.getElementById('signature'), {
    url: sessionUrl, // the URL from step 2, from your server
    minHeight: 600,
    title: 'Sign the rental agreement',
    onEvent(event) {
      if (event.type === 'completed') showCompletion();
      if (event.type === 'declined') showDeclined();
      if (event.type === 'error') showError(event.code);
    },
  });
  // widget.destroy() removes the iframe again.
</script>
```

The widget has no dependencies and sets no cookies. It creates the `iframe` with `allow="camera; microphone; fullscreen"` (the identity check of AES needs the camera at every frame level) and `referrerpolicy="no-referrer"`, adjusts the height (`resize`) and passes on only messages that come from the origin of the signing page and match the protocol.

Without the widget: your own `iframe` with the same attributes works just as well; then listen for `message` yourself and check `event.origin` against the origin of the URL, as well as `source` and `v` of the message.

## 4. Messages of the page

Every message has the form `{ source: "onesign", v: 1, type, …, at }` and **never** contains names, email addresses, tokens or links. The page sends them only to the bound origin (or the checked website of your list), never to `*`.

| `type` | Additional fields | Meaning |
|---|---|---|
| `ready` | `envelopeId`, `recipientId` | Page loaded |
| `resize` | `height` | Height of the page in pixels |
| `signed` | `envelopeId`, `recipientId` | The recipient has signed |
| `approved` | `envelopeId`, `recipientId` | The recipient has approved |
| `viewed` | `envelopeId`, `recipientId` | The recipient has confirmed viewing |
| `declined` | `envelopeId`, `recipientId` | The envelope was declined (also in the signing step) |
| `completed` | `envelopeId` | The envelope is complete |
| `expired` | `envelopeId`, `recipientId` | The envelope has expired |
| `error` | `code` | `link_invalid`, `rate_limited`, `unavailable`, `action_failed`, `envelope_cancelled`, `envelope_failed`, `qes_new_window` |

`envelopeId` and `recipientId` appear only in messages of an embedded session; with a signing link and `?embed=1` they are missing. In embedded mode the page never redirects on its own: react to `completed` (or the webhook). The privacy notice and the footer with the legal notice stay visible in the frame.

## 5. Security and browsers

- **Clickjacking protection:** The signing page allows embedding (`Content-Security-Policy: frame-ancestors`) only for the bound origin or the websites on your list; without `?embed=1` it can never be embedded.
- **No cookies:** Browsers block cookies in third-party frames (for example Safari). The page needs none: the session lives in the memory of the page.
- **QES:** If an embedded link opens the step of a member with a qualified signature, the page shows "Sign in in a new window" (opens a new window) and reports `error` with `qes_new_window`.
- **Live use:** In live mode, embedding is still blocked (403 / 9030 `embedding_live_disabled`) until it has been checked on real devices. In test mode everything is available.
