# Embedded signing, A to Z

People sign inside **your** product: no e-mail, no second website, your colours. This guide takes you from nothing to a signed PDF in your database, and shows how to fit it to the way your business works. Every step has code you can copy.

**You need:** an API key (Console → Developers), a backend you control (any language), and a web page where the person is logged in. **Time:** about an hour for the first working version.

Short on time? Console → **Developers** → step 4 has a builder: pick the options, copy the code, and open the real signing page right there.

---

## 1. What you will build

```
 person in your app          your backend                        PenlyDoc
 ──────────────────          ────────────                        ────────
 1. opens "Sign contract" ─▶ 2. finds the envelope for this person
                             3. POST …/signing-url  (API key) ──▶ fresh link, valid 1 hour
                          ◀─ 4. returns { url }
 5. Penly.mount(url) ─────────────────────────────────────────▶ signing page in an iframe
 6. signs                                                         7. seals the PDF when everyone signed
                             8. webhook envelope.completed  ◀──── signed, with HMAC
                             9. downloads the signed PDF,
                                marks your record as signed
10. sees "Signed" in your app
```

Three rules hold everything together:

1. **The API key stays on your server.** The browser only ever sees a short-lived signing link.
2. **Ask for the link when the person opens the page.** It lives 1 hour. Never store it or e-mail it.
3. **Your database listens to the webhook, not the browser.** Browser events are for your UI only.

## 2. Fit it to your business first

Ten minutes with these questions saves a rewrite later.

**What is one envelope in your world?** One document (or a bundle) that the same people sign together: one rental agreement, one offer letter, one order. Create one envelope per such thing and put **your** ID in `externalRef` (e.g. `lease-2291`). It comes back in every webhook.

**Who signs, and where?** Each person is a *recipient* with a `delivery`:

| The person… | `delivery` | How they sign |
|---|---|---|
| is logged into your app | `EMBEDDED` | Your page shows the signing page (this guide). |
| is outside your app (a landlord, a partner, a manager) | `EMAIL` | PenlyDoc e-mails them a link. |
| should only get the signed copy | role `CC` | Nothing to sign. |

You can mix them in one envelope. A common pattern: your customer signs embedded, your own staff counter-signs by e-mail.

**In which order?** `SEQUENTIAL` (default): one after another by `routingOrder`. `PARALLEL`: everyone at once. An embedded person whose turn has not come yet sees "waiting for …" instead of the sign button, so put a "Sign" button in your UI only when it is their turn (`recipient.sent` webhook).

**Typical set-ups:**

| Business | Envelope | Recipients |
|---|---|---|
| SaaS with customer contracts | one per order | customer `EMBEDDED`, your sales lead `EMAIL` |
| HR / onboarding portal | one per new hire | employee `EMBEDDED`, HR manager `EMAIL`, payroll `CC` |
| Rentals, bookings | one per booking | guest `EMBEDDED` (parallel with host), host `EMBEDDED` |
| Agency / freelance platform | one per project | client `EMBEDDED`, freelancer `EMBEDDED`, sequential |
| Clinic, school, club forms | one per form | the person `EMBEDDED`, nobody else |

**Same document every time?** Make a **template** once (Console → Templates): drag the boxes, name the roles (`customer`, `company`), add grey data boxes (`Price`, `Start date`). Then every envelope is one JSON request with no file upload. Different document every time? Upload it and use tags like `{{sig:1}}` in the text, or coordinates (see the [developer guide](/docs#5-creating-envelopes-in-detail)).

## 3. Allow your website (once)

The browser shows the signing page in a frame only on websites you list. Console → **Settings → Websites allowed to show the signing page**, one per line, or by API:

```bash
curl -X PUT https://penlydoc.com/v1/branding \
  -H "Authorization: Bearer $PENLY_API_KEY" -H "Content-Type: application/json" \
  -d '{"embedOrigins": ["https://app.example.com", "https://staging.example.com", "http://localhost:3000"]}'
```

* Only the **origin**: scheme, host and port. No path, no trailing slash.
* `https://` only, except `http://localhost` and `http://127.0.0.1` for development.
* Up to 20. List staging and every subdomain that shows the page.
* Forgot this? The frame stays empty and the browser console says `Refused to frame … frame-ancestors`.

## 4. Create the envelope with embedded signers

**From a template** (recommended):

```bash
curl https://penlydoc.com/v1/templates/$TEMPLATE_ID/envelopes \
  -H "Authorization: Bearer $PENLY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: lease-2291" \
  -d '{
        "send": true,
        "externalRef": "lease-2291",
        "metadata": {"customerId": "c-81"},
        "roles": {
          "customer": {"name": "Anna Becker", "email": "anna@example.com", "delivery": "EMBEDDED", "language": "en"},
          "company":  {"name": "Northwind Studio", "email": "contracts@northwind.example"}
        },
        "data": {"Price": "1 250 EUR / month", "Start date": "1 November 2026"}
      }'
```

**From your own file:**

```bash
curl https://penlydoc.com/v1/envelopes \
  -H "Authorization: Bearer $PENLY_API_KEY" \
  -H "Idempotency-Key: lease-2291" \
  -F 'envelope={"title":"Lease agreement","externalRef":"lease-2291","send":true,
       "recipients":[{"name":"Anna Becker","email":"anna@example.com","delivery":"EMBEDDED"}]};type=application/json' \
  -F 'documents=@lease.pdf;type=application/pdf'
```

From the answer, **store two IDs** next to your record: `id` (the envelope) and `recipients[i].id` for each embedded person.

```sql
-- example: one row per person who signs inside your app
create table contract_signers (
  contract_id      text not null,   -- your record, also sent as externalRef
  user_id          text not null,   -- your logged-in user
  penly_envelope   uuid not null,
  penly_recipient  uuid not null,
  status           text not null default 'pending',  -- pending | signed | declined
  primary key (contract_id, user_id)
);
```

Why still an `email` for embedded people? It identifies them in the audit trail and the certificate of completion, and it is where the code goes if you turn on `requireOtp`. Embedded people receive **no other e-mails** from PenlyDoc, including no signed copy: you give them the PDF (step 8).

## 5. Your backend: hand out signing links

One small endpoint. It must check that the logged-in user really is this signer, then ask PenlyDoc for a fresh link.

**Node.js (Express)**

```js
app.post('/api/contracts/:contractId/sign-link', requireLogin, async (req, res) => {
  const row = await db.oneOrNone(
    'select penly_envelope, penly_recipient from contract_signers where contract_id = $1 and user_id = $2',
    [req.params.contractId, req.user.id]);
  if (!row) return res.sendStatus(404);           // not this user's contract

  const r = await fetch(`https://penlydoc.com/v1/envelopes/${row.penly_envelope}/recipients/${row.penly_recipient}/signing-url`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.PENLY_API_KEY}` }
  });
  if (!r.ok) return res.status(502).json(await r.json());   // e.g. 409 not_open: voided or expired
  const { url, expiresAt } = await r.json();
  res.set('Cache-Control', 'no-store').json({ url, expiresAt });
});
```

**Python (Flask)**

```python
@app.post("/api/contracts/<contract_id>/sign-link")
@login_required
def sign_link(contract_id):
    row = db.signer_for(contract_id, current_user.id)
    if row is None:
        abort(404)
    r = requests.post(
        f"https://penlydoc.com/v1/envelopes/{row.penly_envelope}/recipients/{row.penly_recipient}/signing-url",
        headers={"Authorization": f"Bearer {os.environ['PENLY_API_KEY']}"}, timeout=10)
    if not r.ok:
        return r.json(), 502
    resp = jsonify(url=r.json()["url"], expiresAt=r.json()["expiresAt"])
    resp.headers["Cache-Control"] = "no-store"
    return resp
```

**Java (Spring)**

```java
@PostMapping("/api/contracts/{contractId}/sign-link")
Map<String, Object> signLink(@PathVariable String contractId, @AuthenticationPrincipal AppUser user) {
    var row = signers.find(contractId, user.id()).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    JsonNode link = RestClient.create("https://penlydoc.com").post()
        .uri("/v1/envelopes/{e}/recipients/{r}/signing-url", row.penlyEnvelope(), row.penlyRecipient())
        .header("Authorization", "Bearer " + apiKey)
        .retrieve().body(JsonNode.class);
    return Map.of("url", link.get("url").asText(), "expiresAt", link.get("expiresAt").asText());
}
```

**PHP (Laravel)**

```php
Route::post('/api/contracts/{contract}/sign-link', function (string $contract) {
    $row = DB::table('contract_signers')->where(['contract_id' => $contract, 'user_id' => auth()->id()])->firstOrFail();
    $r = Http::withToken(env('PENLY_API_KEY'))
        ->post("https://penlydoc.com/v1/envelopes/{$row->penly_envelope}/recipients/{$row->penly_recipient}/signing-url");
    abort_unless($r->ok(), 502);
    return response()->json($r->only(['url', 'expiresAt']))->header('Cache-Control', 'no-store');
})->middleware('auth');
```

The link works only for that one person and that one envelope, and stops working after an hour, when the envelope ends, or when you call `POST /v1/envelopes/{id}/recipients/{rid}/revoke-links`.

## 6. Your page: show the signing page

**Plain HTML**

```html
<div id="sign"></div>
<script src="https://penlydoc.com/embed.js"></script>
<script type="module">
  const res = await fetch('/api/contracts/lease-2291/sign-link', { method: 'POST' });
  const { url } = await res.json();

  const signing = Penly.mount('#sign', {
    url,
    language: 'en',
    height: '80vh',
    hideHeader: true,                  // your page already shows the title
    onReady:     e => console.log('signing page shown, status', e.status),
    onCompleted: e => showDone(e.envelopeStatus),   // "PARTIALLY_SIGNED" or "COMPLETED"
    onDeclined:  e => showDeclined(),
    onError:     e => console.warn('signing error', e.code)
  });
</script>
```

**React**

```jsx
import { useEffect, useRef } from 'react';

function loadPenly() {
  if (window.Penly) return Promise.resolve();
  return (window.__penly ||= new Promise((ok, fail) => {
    const s = document.createElement('script');
    s.src = 'https://penlydoc.com/embed.js'; s.onload = ok; s.onerror = fail;
    document.head.append(s);
  }));
}

export function SignContract({ contractId, onSigned }) {
  const box = useRef(null);
  useEffect(() => {
    let signing, cancelled = false;
    (async () => {
      await loadPenly();
      const { url } = await (await fetch(`/api/contracts/${contractId}/sign-link`, { method: 'POST' })).json();
      if (cancelled) return;
      signing = window.Penly.mount(box.current, { url, height: '80vh', hideHeader: true, onCompleted: onSigned });
    })();
    return () => { cancelled = true; signing?.destroy(); };
  }, [contractId]);
  return <div ref={box} />;
}
```

**Vue 3**

```vue
<template><div ref="box" /></template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({ contractId: String });
const emit = defineEmits(['signed']);
const box = ref(null); let signing;
onMounted(async () => {
  if (!window.Penly) await new Promise(ok => { const s = document.createElement('script'); s.src = 'https://penlydoc.com/embed.js'; s.onload = ok; document.head.append(s); });
  const { url } = await (await fetch(`/api/contracts/${props.contractId}/sign-link`, { method: 'POST' })).json();
  signing = window.Penly.mount(box.value, { url, height: '80vh', onCompleted: e => emit('signed', e) });
});
onBeforeUnmount(() => signing?.destroy());
</script>
```

Ask for a new link every time the component mounts. If the person leaves the tab open for more than an hour, the page says the link expired: remount to get a fresh one.

## 7. Options and events

**`Penly.mount(target, options)`**

| Option | Type | Default | What it does |
|---|---|---|---|
| `url` | string | required | The link from your backend. Must be a PenlyDoc link. |
| `language` | `en` `sk` `cs` `uk` `fr` | the signer's language | Language of the page. |
| `height` | CSS length | `80vh` | Height of the frame. The page scrolls inside it. |
| `hideHeader` | boolean | `false` | Hides the title bar (title, name, language picker). |
| `hideLanguagePicker` | boolean | `false` | Keeps the title bar, hides only the language picker. |
| `accentColor` | `#rrggbb` | your brand colour | Buttons and highlights for this embed only. Text colour on it is picked for contrast. |
| `title` | string | `Sign document` | Frame title for screen readers. |
| `onReady` `onCompleted` `onDeclined` `onError` | function | none | Called with the event below. |
| `onEvent` | function | none | Called for every event; `e.type` tells which. |

It returns `{ iframe, setHeight(css), destroy() }`. Call `destroy()` when your view goes away.

Your organisation's name, logo, colour and default language come from **Branding** (Console → Settings, or `PUT /v1/branding`), and they apply to e-mails and the certificate too. The options above only change this one frame. The privacy link and the small "PenlyDoc" line at the bottom always stay: signers have the right to know who processes their data.

**Events** (every event has `source: "penly"` and `type`):

| `type` | Extra fields | When |
|---|---|---|
| `ready` | `status`: `SENT`, `VIEWED`, `COMPLETED`, … | The page loaded and shows the document. |
| `completed` | `envelopeStatus`: `PARTIALLY_SIGNED` or `COMPLETED` | This person signed. |
| `declined` | `envelopeStatus`: `DECLINED` | This person declined (with a reason they typed). |
| `error` | `code`, e.g. `otp_invalid` | Submitting failed; the page already shows the message. |

The page sends events only to the websites in your embed list, and `embed.js` accepts them only from PenlyDoc and only from its own frame. Still, anything in a browser can be faked, so treat events as "update the screen", never as "mark as signed in the database".

## 8. Webhooks: the source of truth

Set the URL once (Console → Settings → Webhook, or `PUT /v1/webhook`) and keep the `whsec_` secret on your server. Then handle three events:

```js
app.post('/webhooks/penly', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!validSignature(req)) return res.sendStatus(401);          // see the developer guide, section 10
  const event = JSON.parse(req.body);
  if (await db.seen(event.id)) return res.sendStatus(204);       // Penly-Event-Id: deliveries can repeat
  const { externalRef, envelopeId, recipient } = event.data;

  switch (event.type) {
    case 'recipient.sent':        // it is this person's turn: show "Sign" in your app, maybe notify them
      await db.markTurn(externalRef, recipient.email); break;
    case 'recipient.completed':   // this person signed
      await db.markSigner(externalRef, recipient.email, 'signed'); break;
    case 'recipient.declined':
      await db.markSigner(externalRef, recipient.email, 'declined', recipient.declineReason); break;
    case 'envelope.completed': {  // everyone signed: fetch and keep the sealed PDF
      const pdf = await fetch(`https://penlydoc.com/v1/envelopes/${envelopeId}/documents/signed`,
        { headers: { Authorization: `Bearer ${process.env.PENLY_API_KEY}` } });
      await storage.put(`contracts/${externalRef}.pdf`, Buffer.from(await pdf.arrayBuffer()));
      await db.markContract(externalRef, 'signed');
      await mailer.sendSignedCopy(externalRef);   // embedded signers get no e-mail from PenlyDoc
      break;
    }
    case 'envelope.voided': case 'envelope.expired': case 'envelope.declined':
      await db.markContract(externalRef, event.type.split('.')[1]); break;
  }
  res.sendStatus(204);
});
```

Answer within 10 seconds; do slow work in a queue. Failed deliveries are retried up to 12 times with growing pauses; you see them in Console → Settings → recent deliveries.

## 9. Full-page mode (mobile apps, no iframe)

Sometimes a frame is the wrong tool: a native mobile app, a page with a strict layout, or a person who opens the contract from your own e-mail. Then open the signing page as a full page and let it send the person back.

1. Ask for the link with a `returnUrl` on one of your allowed websites:

```bash
curl -X POST https://penlydoc.com/v1/envelopes/$ENVELOPE_ID/recipients/$RECIPIENT_ID/signing-url \
  -H "Authorization: Bearer $PENLY_API_KEY" -H "Content-Type: application/json" \
  -d '{"returnUrl": "https://app.example.com/contracts/lease-2291/done"}'
```

2. Redirect the browser to `url` (web), or open it in `SFSafariViewController` (iOS) / Custom Tabs (Android) / the system browser (React Native, Flutter). Avoid a raw WebView: password managers and the drawing pad work better in the real browser.
3. After signing or declining, the page waits a moment and sends the person to your address with two parameters added:

```
https://app.example.com/contracts/lease-2291/done?penly_event=completed&penly_status=PARTIALLY_SIGNED
```

`penly_event` is `completed` or `declined`. As with browser events, show a message and let the webhook update your database. A `returnUrl` on a website that is not in your list is refused with `400 return_url_not_allowed`, so a signing link can never send people somewhere you did not choose. Inside a frame the return address is ignored.

## 10. Special cases

**Several people sign inside your app.** Each one has their own recipient ID and gets their own link from your endpoint. With `SEQUENTIAL`, show the button to the next person after their `recipient.sent` webhook.

**Someone has to sign who is not logged in.** Use `EMAIL` for that person, or create the link on your side and deliver it the way your product already talks to them. Embedded links last only an hour, so `EMAIL` (link valid until the envelope expires) is usually the better choice.

**Extra check of who signs.** Set `"requireOtp": true` on the recipient. Before signing, the page asks for a 6-digit code sent to their e-mail. It works the same inside the frame.

**Wrong person or changed e-mail.** `POST /v1/envelopes/{id}/recipients/{rid}/revoke-links` stops all links of that person. To change people, `void` the envelope and `duplicate` it with the new details.

**Cancel.** `POST /v1/envelopes/{id}/void` with `{"reason": "…"}`. Its links stop working; an open frame shows an error if the person tries to sign.

**Layout.** Give the frame most of the screen: `height: '80vh'` on desktop, `100dvh` minus your header on phones. The action bar sits at the bottom of the frame, so do not place your own sticky footer over it.

**Languages.** Set `language` per recipient when you create the envelope; it is used for the page and for the code e-mail. The `language` option of `mount` overrides it for that frame. Available: English, Slovak, Czech, Ukrainian, French.

**Several brands or customers of yours.** One PenlyDoc organisation has one branding. If you serve several brands that each need their own name and logo on the signing page, use one organisation per brand, each with its own API key.

## 11. Security in one page

* The API key and the webhook secret live only on your server. Mobile apps call **your** backend, never PenlyDoc with the key.
* Your link endpoint checks that the logged-in user owns the contract. This is the one check only you can do.
* The signing link is a signed token that names one person and one envelope and expires in an hour. The page needs no cookies, so it works in Safari and other browsers that block third-party cookies.
* The page may be framed only by your listed websites (`Content-Security-Policy: frame-ancestors`), which stops other sites from hiding it in a frame and tricking people into clicking.
* Events go only to your websites and are accepted only from PenlyDoc's frame. The database still follows the webhook, which is signed with HMAC.
* Every signed PDF carries a seal, a trusted timestamp and a certificate page with who signed, when and from where. Anyone can check it at [/verify](/verify).

## 12. Test it locally

1. Add `http://localhost:3000` (your dev port) to the allowed websites.
2. Create an envelope with yourself as an `EMBEDDED` recipient. Use an address you own; no e-mail is sent.
3. Run your app, open the page, sign with the mouse.
4. For webhooks on your laptop, expose it with a tunnel (`ngrok http 3000`, `cloudflared tunnel`) and set that URL as the webhook; switch back for production. **Developers → Send a test event** shows whether it arrives.
5. Or skip your app entirely: Console → Developers → step 4 opens the real page with your chosen options and lists the events it sends.

## 13. When something does not work

| You see | Why | Fix |
|---|---|---|
| Empty frame, console: `Refused to frame … frame-ancestors` | Your website is not in the list | Add the exact origin (scheme, host, port) in Settings. |
| `PenlyDoc: url must point to https://penlydoc.com` | The link is not a PenlyDoc link, or `embed.js` is from another host | Load `embed.js` from the same host the link points to. |
| Page says the link is invalid or expired | Older than 1 hour, revoked, or the envelope ended | Ask your backend for a new link; check the envelope status. |
| `409 not_open` from `/signing-url` | The envelope is a draft, voided or expired | Send the draft first, or create a new envelope. |
| "Waiting for …" instead of the sign button | Sequential order: not this person's turn yet | Show the button after `recipient.sent`. |
| Events never arrive | Callback set on the wrong frame, or the page is not on a listed website | Use the object from `Penly.mount`; check the list. |
| `400 return_url_not_allowed` | `returnUrl` on a website that is not listed | Add that origin, or use another return address. |
| Webhook shows 401 in deliveries | Signature check on a parsed body | Check the HMAC on the **raw** body. |

## 14. Going live

- [ ] Production origin (and staging) in the allowed websites; `localhost` removed when you no longer need it.
- [ ] API key and webhook secret in your secret store; one key per system.
- [ ] Link endpoint checks the logged-in user and sends `Cache-Control: no-store`.
- [ ] Links requested on demand, never stored, never logged.
- [ ] `Idempotency-Key` and `externalRef` on every envelope.
- [ ] Webhook: signature and timestamp checked, `Penly-Event-Id` deduplicated, answers in under 10 s.
- [ ] Signed PDF downloaded on `envelope.completed` and given to embedded signers (they get no e-mail).
- [ ] Your UI handles `PARTIALLY_SIGNED`, `DECLINED`, `VOIDED`, `EXPIRED` as well as `COMPLETED`.
- [ ] Tested on a phone, in Safari, and with a second signer.

## 15. Complete example

One file backend (Node 18+, Express) and one page. Replace the template ID and the in-memory "database" with yours.

```js
// server.js   →   PENLY_API_KEY=sk_… PENLY_WEBHOOK_SECRET=whsec_… TEMPLATE_ID=… node server.js
import express from 'express';
import crypto from 'node:crypto';

const BASE = 'https://penlydoc.com';
const KEY = process.env.PENLY_API_KEY;
const contracts = new Map();   // contractId -> { envelopeId, recipientId, status }
const app = express();

const penly = (path, init = {}) => fetch(BASE + path, {
  ...init, headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json', ...init.headers }
}).then(async r => { const body = await r.json(); if (!r.ok) throw new Error(body.detail); return body; });

// 1. Create a contract for the logged-in user (here: fixed demo user)
app.post('/api/contracts', express.json(), async (req, res) => {
  const contractId = 'c-' + Date.now();
  const env = await penly(`/v1/templates/${process.env.TEMPLATE_ID}/envelopes`, {
    method: 'POST', headers: { 'Idempotency-Key': contractId },
    body: JSON.stringify({ send: true, externalRef: contractId,
      roles: { customer: { name: 'Anna Becker', email: 'anna@example.com', delivery: 'EMBEDDED' } },
      data: {} })
  });
  contracts.set(contractId, { envelopeId: env.id, recipientId: env.recipients[0].id, status: 'pending' });
  res.json({ contractId });
});

// 2. Hand out a fresh signing link (check ownership here in a real app)
app.post('/api/contracts/:id/sign-link', async (req, res) => {
  const c = contracts.get(req.params.id);
  if (!c) return res.sendStatus(404);
  const link = await penly(`/v1/envelopes/${c.envelopeId}/recipients/${c.recipientId}/signing-url`, { method: 'POST' });
  res.set('Cache-Control', 'no-store').json({ url: link.url });
});

app.get('/api/contracts/:id', (req, res) => res.json(contracts.get(req.params.id) || {}));

// 3. The webhook decides what is signed
app.post('/webhooks/penly', express.raw({ type: 'application/json' }), (req, res) => {
  const { t, v1 } = Object.fromEntries((req.get('Penly-Signature') || '').split(',').map(p => p.split('=')));
  const expected = crypto.createHmac('sha256', process.env.PENLY_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  const c = contracts.get(event.data?.externalRef);
  if (c && event.type === 'envelope.completed') c.status = 'signed';
  if (c && event.type === 'envelope.declined') c.status = 'declined';
  res.sendStatus(204);
});

app.use(express.static('public'));
app.listen(3000, () => console.log('http://localhost:3000'));
```

```html
<!-- public/index.html -->
<!doctype html>
<meta charset="utf-8">
<title>Sign your contract</title>
<button id="start">Create and sign a contract</button>
<p id="msg"></p>
<div id="sign"></div>
<script src="https://penlydoc.com/embed.js"></script>
<script type="module">
  const msg = document.getElementById('msg');
  document.getElementById('start').onclick = async () => {
    const { contractId } = await (await fetch('/api/contracts', { method: 'POST' })).json();
    const { url } = await (await fetch(`/api/contracts/${contractId}/sign-link`, { method: 'POST' })).json();
    const signing = Penly.mount('#sign', {
      url, height: '80vh', hideHeader: true,
      onCompleted: async () => {
        msg.textContent = 'Thank you! Confirming…';
        for (let i = 0; i < 10; i++) {                        // wait for the webhook to land
          const c = await (await fetch(`/api/contracts/${contractId}`)).json();
          if (c.status === 'signed') { msg.textContent = 'Signed.'; signing.destroy(); return; }
          await new Promise(r => setTimeout(r, 1500));
        }
      },
      onDeclined: () => { msg.textContent = 'You declined.'; }
    });
  };
</script>
```

Add `http://localhost:3000` to the allowed websites, start the server, open the page, sign. That is the whole loop. Next: the [developer guide](/docs) for templates, tags, webhooks and errors in depth, and the [API reference](/swagger-ui.html) for every field.

Questions: [support@penlydoc.com](mailto:support@penlydoc.com).
