# PenlyDoc developer guide

Everything you need to connect your product to PenlyDoc: send documents for signature from your backend, show the signing page inside your app, and react when people sign. Written so that a junior developer can follow it from top to bottom; senior developers can jump to the [API reference](#18-api-reference).

**Base URL:** `https://penlydoc.com` · **Format:** JSON over HTTPS · **Auth:** API key in the `Authorization` header · **Interactive reference:** [/swagger-ui.html](/swagger-ui.html)

---

## 1. How it fits together

```
 ┌──────────────┐  1. send document   ┌──────────┐
 │ your backend │ ──────────────────▶ │ PenlyDoc │ ──e-mail──▶ person signs by link
 │              │ ◀────────────────── │          │
 │              │  3. webhook events  │          │ ◀─iframe──  person signs inside your app (embed.js)
 └──────┬───────┘                     └──────────┘
        │ 2. signing link (embedded signers only)
 ┌──────▼───────┐
 │ your web page│  shows the signing page with embed.js
 └──────────────┘
```

1. **Your backend** creates an *envelope* (a document plus the people who sign it). You get back an envelope ID: store it next to your own record.
2. People sign either **by e-mail** (PenlyDoc sends the link) or **inside your app** (your backend asks for a signing link, your page shows it with `embed.js`).
3. PenlyDoc calls your **webhook** for every step: sent, viewed, signed, completed, declined, voided, expired.
4. When the envelope is **completed**, you download the sealed PDF and keep it.

That is the whole integration: one or two calls out, one endpoint in.

## 2. Words you will see

| Word | Meaning |
|---|---|
| **Organisation** | Your company's account in PenlyDoc. Everything (documents, keys, team) belongs to one organisation. |
| **Envelope** | One sending: a PDF, the people, the boxes they fill, and its status. |
| **Recipient** | A person in the envelope. Role `SIGNER` (signs), `APPROVER` (approves, no signature) or `CC` (gets a copy). |
| **Field / box** | A place on the page: `SIGNATURE`, `INITIALS`, `DATE`, `NAME`, `TEXT`, `CHECKBOX`. Belongs to one recipient. |
| **Template** | A reusable document with *roles* (`client`, `company`) instead of people. You fill the roles and data when sending. |
| **Data field** | A grey box in a template that **you** fill when sending (price, start date…). Sent as `data`. |
| **Webhook** | An HTTPS POST PenlyDoc sends to your server when something happens. |
| **Embed** | Showing the signing page inside your web app in an iframe, with `embed.js`. |
| **API key** | `sk_…`, the password of your organisation for the API. **Server side only.** |
| **Webhook secret** | `whsec_…`, used to check a webhook really comes from PenlyDoc. **Server side only.** |

## 3. Get access (5 minutes)

1. Create an organisation at [penlydoc.com/console#/signup](/console#/signup). You sign in with a 6-digit code sent by e-mail; there is no password.
2. Right after sign-up the first **API key** is shown once. Copy it into your secret store. Lost it? **Console → Developers → API keys → Create key.** Give each system its own named key ("CRM", "Website") and revoke a key if it leaks.
3. A colleague integrates? Invite them as **Admin** (Settings → Team). Admins can create keys, set the webhook and branding; Members can only send documents.

Check the key works:

```bash
export PENLY_API_KEY=sk_...
curl https://penlydoc.com/v1/me -H "Authorization: Bearer $PENLY_API_KEY"
# {"id":"…","name":"Northwind Studio","displayName":"Northwind Studio","user":null,"sendLimit":{…},"features":{…}}
```

> `Bearer` is just the standard way to pass the key: `Authorization: Bearer sk_…`. There is no separate token to obtain.

## 4. Your first signature (cURL)

Any PDF works. Put the boxes by coordinates (section 7) or let PenlyDoc find tags in the text (section 8). The quickest start is a PDF that contains the text `{{sig:1}}` where the first person signs.

```bash
curl https://penlydoc.com/v1/envelopes \
  -H "Authorization: Bearer $PENLY_API_KEY" \
  -H "Idempotency-Key: order-42-contract" \
  -F 'envelope={
        "title": "Service agreement",
        "message": "Please sign by Friday.",
        "externalRef": "order-42",
        "send": true,
        "recipients": [
          {"name": "Anna Becker", "email": "anna@example.com"}
        ]
      };type=application/json' \
  -F 'documents=@service-agreement.pdf;type=application/pdf'
```

Answer (shortened):

```json
{
  "id": "6b0c4f1e-…",
  "status": "SENT",
  "externalRef": "order-42",
  "recipients": [{ "id": "a1…", "position": 1, "name": "Anna Becker", "status": "SENT", "delivery": "EMAIL" }],
  "fields": [{ "type": "SIGNATURE", "page": 1, "recipient": 1, "source": "ANCHOR" }]
}
```

Anna gets an e-mail with a link, signs on her phone, and the envelope becomes `COMPLETED`. Then:

```bash
curl -o signed.pdf https://penlydoc.com/v1/envelopes/6b0c4f1e-…/documents/signed \
  -H "Authorization: Bearer $PENLY_API_KEY"
```

## 5. Creating envelopes in detail

`POST /v1/envelopes` takes `multipart/form-data` with two parts:

* `envelope`: JSON (content type `application/json`), described below.
* `documents`: one or more files. PDF, PNG or JPEG; several files are joined in the order sent (handy for photos of paper pages). Up to 25 MB per file, 60 MB per request, 300 pages. Encrypted PDFs are refused.

| Field | Type | Default | Notes |
|---|---|---|---|
| `title` | string ≤ 300 | required | Subject of the e-mail and name of the document. |
| `message` | string ≤ 4000 | none | Shown in the invitation e-mail and on the signing page. |
| `externalRef` | string ≤ 200 | none | **Your** ID. Comes back in every webhook. Search with `GET /v1/envelopes?externalRef=…`. |
| `metadata` | object, ≤ 50 string values | `{}` | Anything you want back in webhooks. |
| `signingOrder` | `SEQUENTIAL` \| `PARALLEL` | `SEQUENTIAL` | Sequential: one after another by `routingOrder`. Parallel: everybody at once. |
| `expiresInDays` | 1–365 | 30 | After this the envelope becomes `EXPIRED`. |
| `reminderEveryHours` | 0–720 | organisation default | Automatic reminders to people who have not signed. `0` = off. |
| `send` | boolean | `false` | `true` sends right away; `false` creates a `DRAFT` you can edit and send later. |
| `recipients` | array, 1–25 | required | See below. |
| `fields` | array ≤ 500 | `[]` | Boxes by coordinates (section 7). Tags in the PDF are added automatically. |
| `data` | object | `{}` | Values for `{{data:Key}}` tags (section 8). |

**Recipient:**

| Field | Type | Default | Notes |
|---|---|---|---|
| `name` | string ≤ 200 | required | Shown on the signing page and in the certificate. |
| `email` | e-mail | required | Also for embedded signers: it identifies them in the audit trail. |
| `role` | `SIGNER` \| `APPROVER` \| `CC` | `SIGNER` | CC only receives the signed copy. |
| `routingOrder` | 1–100 | list position (1 for everyone when `PARALLEL`) | Same number = same step: they act at the same time. |
| `delivery` | `EMAIL` \| `EMBEDDED` | `EMAIL` | `EMBEDDED`: no e-mail, you show the page in your app (section 9). |
| `requireOtp` | boolean | `false` | The signer must enter a 6-digit code sent to their e-mail before signing. |
| `language` | `en` `sk` `cs` `uk` `fr` | organisation default | Language of the signing page and the e-mails for this person. |

Every signer needs at least one `SIGNATURE` box before the envelope can be sent (`signer_without_signature_field` otherwise). A draft may have none yet.

**Idempotency.** Send an `Idempotency-Key` header (≤ 200 characters, e.g. `order-42-contract`) on every create. If your request times out and you retry with the same key and the same body, you get the same envelope back instead of a second one. The same key with a different body returns `422 idempotency_key_reused`.

## 6. Templates (the comfortable way)

If you send the same document again and again, make it a template once (in the console: **Templates → New template**, drag the boxes, name the roles), then send it with one JSON request. No file upload, no coordinates.

```bash
curl https://penlydoc.com/v1/templates/$TEMPLATE_ID/envelopes \
  -H "Authorization: Bearer $PENLY_API_KEY" \
  -H "Idempotency-Key: order-42-contract" \
  -H "Content-Type: application/json" \
  -d '{
        "send": true,
        "externalRef": "order-42",
        "roles": {
          "client":  {"name": "Anna Becker", "email": "anna@example.com", "language": "en"},
          "company": {"name": "Northwind Studio", "email": "office@northwind.example", "delivery": "EMBEDDED"}
        },
        "data": {"Price": "4 800 EUR", "Start date": "3 November 2026"}
      }'
```

* `roles`: one entry per role key of the template. Missing or extra roles → `400 roles_mismatch`.
* `data`: one value per grey box / `{{data:…}}` tag. Missing values → `400 missing_data` lists the names.
* Optional overrides: `title`, `message`, `metadata`, `expiresInDays`, `reminderEveryHours`.
* `GET /v1/templates` lists templates with `ready` (every role has a signature box) and `dataKeys` (the values you must send). The **Developers** page in the console prints a ready-made request for your first ready template.

Templates can also be created by API: `POST /v1/templates` with parts `template` (JSON: `name`, `title`, `roles[]` with `key` like `client`, optional `fields`) and `documents`.

## 7. Boxes by coordinates

Coordinates are PDF points (1/72 inch) measured from the **top-left** corner of the page. An A4 page is 595 × 842 points.

```json
"fields": [
  { "recipient": 1, "type": "SIGNATURE", "page": 1, "x": 60,  "y": 700, "width": 180, "height": 48 },
  { "recipient": 1, "type": "DATE",      "page": 1, "x": 260, "y": 712 },
  { "recipient": 2, "type": "TEXT",      "page": 2, "x": 60,  "y": 120, "label": "IBAN", "required": true },
  { "recipient": 2, "type": "CHECKBOX",  "page": 2, "x": 60,  "y": 160, "label": "I accept the terms" }
]
```

`recipient` is the 1-based position in `recipients`. `width`/`height` have sensible defaults per type. A box outside the page → `400 field_off_page`. You can replace the boxes of a draft with `PUT /v1/envelopes/{id}/fields`.

## 8. Tags in the document

Write tags in your Word/Google Docs template (white text is fine, PenlyDoc paints over them) and export to PDF. PenlyDoc turns them into boxes at that exact spot:

| Tag | Box |
|---|---|
| `{{sig:1}}` | signature of recipient/role 1 |
| `{{init:1}}` | initials |
| `{{date:1}}` | date of signing, filled automatically |
| `{{name:1}}` | full name, filled automatically |
| `{{text:2:IBAN}}` | text the person types; `IBAN` is the hint |
| `{{check:2:Terms accepted}}` | checkbox |
| `{{check?:2:Newsletter}}` | `?` makes any box optional |
| `{{data:Price}}` | value **you** send in `data`; never filled by a signer |

## 9. Signing inside your app (embed)

For people who are logged into your product, skip the e-mail and show the signing page right there.

**Once:** add your web app's origin (e.g. `https://app.example.com`, no path) in **Console → Settings → Websites allowed to show the signing page**, or with `PUT /v1/branding {"embedOrigins": ["https://app.example.com"]}`. Without it the browser shows "content is blocked".

**Each time:**

1. Create the recipient with `"delivery": "EMBEDDED"`.
2. When the person opens your page, your **backend** asks for a fresh link (valid 1 hour, so request it on demand, never store it):

```bash
curl -X POST https://penlydoc.com/v1/envelopes/$ENVELOPE_ID/recipients/$RECIPIENT_ID/signing-url \
  -H "Authorization: Bearer $PENLY_API_KEY"
# {"url":"https://penlydoc.com/s/eyJ…","token":"eyJ…","expiresAt":"…"}
```

3. Your **page** shows it:

```html
<div id="sign"></div>
<script src="https://penlydoc.com/embed.js"></script>
<script>
  const signing = Penly.mount('#sign', {
    url: signingUrlFromYourBackend,
    language: 'en',            // optional: en | sk | cs | uk | fr
    height: '80vh',            // optional
    onReady:     e => console.log('shown', e.status),
    onCompleted: e => showThankYou(e.envelopeStatus),   // e.g. "COMPLETED" or "PARTIALLY_SIGNED"
    onDeclined:  e => showDeclined(),
    onError:     e => console.warn(e.code)
  });
  // later: signing.destroy();
</script>
```

The browser events are for your UI only. **Trust the webhook** (or `GET /v1/envelopes/{id}`) for your database, because anything in the browser can be faked.

To cancel links that are already out (e.g. the person changed their e-mail): `POST /v1/envelopes/{id}/recipients/{rid}/revoke-links`.

## 10. Webhooks

**Set up:** Console → Settings → Webhook, or:

```bash
curl -X PUT https://penlydoc.com/v1/webhook \
  -H "Authorization: Bearer $PENLY_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://app.example.com/webhooks/penly"}'
# {"url":"https://app.example.com/webhooks/penly","secret":"whsec_…"}
```

The secret is shown only here; every call rotates it. Send a test event from **Developers → Send a test event** or `POST /v1/webhook-deliveries/test`.

**What arrives** (`POST`, `Content-Type: application/json`):

```http
Penly-Event-Id: 2f6d…            ← unique per event, use it to ignore duplicates
Penly-Event-Type: envelope.completed
Penly-Signature: t=1792240000,v1=5c2f…
```
```json
{
  "id": "2f6d…",
  "type": "envelope.completed",
  "createdAt": "2026-10-17T12:04:00Z",
  "data": {
    "envelopeId": "6b0c4f1e-…",
    "status": "COMPLETED",
    "externalRef": "order-42",
    "templateId": "…",
    "metadata": {"orderId": "42"},
    "finalSha256": "5d51…",
    "recipient": {"id": "a1…", "position": 1, "email": "anna@example.com", "role": "SIGNER", "status": "COMPLETED"}
  }
}
```

`recipient` is present on recipient events; `templateId` only for template envelopes; `finalSha256` once the signed PDF exists; `declineReason` inside `recipient` when someone declines.

**Events:**

| Type | When |
|---|---|
| `envelope.sent` | The envelope was sent. |
| `recipient.sent` | It is this person's turn (invitation e-mailed, or ready to embed). |
| `recipient.viewed` | The person opened the document. |
| `recipient.completed` | The person signed or approved. |
| `envelope.completed` | Everyone has signed; the sealed PDF is ready. |
| `recipient.declined` | The person declined (`declineReason` included). |
| `envelope.declined` | …and therefore the envelope stopped. |
| `envelope.voided` | You cancelled it (`POST /v1/envelopes/{id}/void`). |
| `envelope.expired` | It expired before everyone signed. |
| `test.ping` | Test event from the console or API. |

**Check the signature** before trusting anything. The signed text is `t + "." + raw body`, HMAC-SHA256 with your `whsec_` secret, hex. Reject if `t` is older than 5 minutes.

Node.js (Express):

```js
import crypto from 'node:crypto';
import express from 'express';
const app = express();

app.post('/webhooks/penly', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Penly-Signature') || '';
  const { t, v1 } = Object.fromEntries(header.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(v1), Buffer.from(expected))) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // 1. ignore if you already processed event.id   2. update your record found by event.data.externalRef
  res.sendStatus(204);
});
```

Java (Spring):

```java
@PostMapping("/webhooks/penly")
ResponseEntity<Void> penly(@RequestHeader("Penly-Signature") String header, @RequestBody String body) throws Exception {
    Map<String, String> p = Arrays.stream(header.split(",")).map(s -> s.split("=", 2))
            .collect(Collectors.toMap(a -> a[0], a -> a[1]));
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    String expected = HexFormat.of().formatHex(mac.doFinal((p.get("t") + "." + body).getBytes(StandardCharsets.UTF_8)));
    boolean fresh = Math.abs(Instant.now().getEpochSecond() - Long.parseLong(p.get("t"))) < 300;
    if (!fresh || !MessageDigest.isEqual(expected.getBytes(), p.getOrDefault("v1", "").getBytes())) {
        return ResponseEntity.status(401).build();
    }
    JsonNode event = mapper.readTree(body);   // dedupe on event.get("id"), then update your record
    return ResponseEntity.noContent().build();
}
```

Python (Flask):

```python
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)

@app.post("/webhooks/penly")
def penly():
    parts = dict(p.split("=", 1) for p in request.headers.get("Penly-Signature", "").split(","))
    body = request.get_data(as_text=True)
    expected = hmac.new(os.environ["PENLY_WEBHOOK_SECRET"].encode(), f"{parts.get('t')}.{body}".encode(), hashlib.sha256).hexdigest()
    if abs(time.time() - int(parts.get("t", 0))) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(401)
    event = request.get_json()   # dedupe on event["id"], then update your record
    return "", 204
```

**Rules for a calm life:**

* Answer `2xx` within 10 seconds; do heavy work in a background job.
* Anything else is retried with growing pauses (30 s, 1 min, 2 min … up to 6 h), 12 attempts in total. You can resend a delivery from the console or with `POST /v1/webhook-deliveries/{id}/retry`.
* Deliveries can arrive twice or out of order. Deduplicate on `Penly-Event-Id`, and when in doubt fetch the current state with `GET /v1/envelopes/{id}`.
* Keep the raw body for the signature check: parse JSON only after verifying.

## 11. Downloads, history and checks

| What | Call |
|---|---|
| Original PDF | `GET /v1/envelopes/{id}/documents/original` |
| Signed, sealed PDF with the certificate of completion as last page (after `COMPLETED`) | `GET /v1/envelopes/{id}/documents/signed` |
| Audit trail, hash-chained, with `chainValid` | `GET /v1/envelopes/{id}/audit` |
| Check any signed PDF (public, nothing stored) | `POST /api/verify` with part `document`, or the page [/verify](/verify) |

Store the signed PDF in your own system as soon as you get `envelope.completed`. The `finalSha256` in the webhook lets you confirm you stored the right bytes.

**About the seal.** Every signed PDF carries a PAdES digital seal and a trusted RFC 3161 timestamp. Any change to the file breaks the seal. Adobe Reader may show "signature validity is unknown" because the seal certificate is issued by PenlyDoc rather than one on Adobe's own trust list; the document is still protected, and `/verify` confirms it.

## 12. Managing envelopes

| Action | Call | Notes |
|---|---|---|
| Get one | `GET /v1/envelopes/{id}` | Full status, recipients, fields. |
| Search | `GET /v1/envelopes?externalRef=order-42&status=SENT` | Both filters optional. |
| Send a draft | `POST /v1/envelopes/{id}/send` | Only `DRAFT`. |
| Remind now | `POST /v1/envelopes/{id}/remind` | E-mails everyone whose turn it is; returns `{"reminded": n}`. |
| Cancel | `POST /v1/envelopes/{id}/void` with `{"reason": "…"}` | People still to act are told. |
| Use again | `POST /v1/envelopes/{id}/duplicate` | New draft with the same document and boxes; optionally new `recipients` (same count and order) and `title`. |
| Delete for good | `DELETE /v1/envelopes/{id}` | Removes the PDFs, people, history and webhook log. Links stop working. Use it to answer deletion requests. |

**Statuses:** `DRAFT` → `SENT` → `PARTIALLY_SIGNED` → `COMPLETED`, or `DECLINED` / `VOIDED` / `EXPIRED`. The last four are final.

## 13. Branding and languages

`PUT /v1/branding` (any field optional):

```json
{ "brandName": "Northwind Studio", "accentColor": "#0f766e", "defaultLanguage": "en",
  "embedOrigins": ["https://app.example.com"] }
```

Logo: `PUT /v1/branding/logo` with part `logo` (PNG or JPEG). Signers see your name, colour and logo on the signing page and in the e-mails; PenlyDoc stays a small line at the bottom. Languages: `en`, `sk`, `cs`, `uk`, `fr`, per organisation (`defaultLanguage`) or per recipient (`language`).

## 14. Errors

Errors are JSON ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)) with a stable `code` you can branch on:

```json
{ "status": 400, "code": "missing_data", "detail": "The document needs values for: Price, Start date. Pass them in \"data\"." }
```

| HTTP | `code` | What to do |
|---|---|---|
| 401 | `missing_api_key`, `invalid_api_key` | Send `Authorization: Bearer sk_…`; the key may be revoked. |
| 403 | `console_only` | Team and keys are managed in the console, not with an API key. |
| 400 | `validation_failed` | `detail` names the field, e.g. `recipients[0].email: must be a well-formed email address`. |
| 400 | `signer_without_signature_field` | Give every signer a `SIGNATURE` box (or a `{{sig:n}}` tag). |
| 400 | `missing_data`, `roles_mismatch` | Template request: send every role and every data value. |
| 400 | `field_off_page`, `invalid_page` | A box lies outside the page. |
| 400 | `unsupported_file`, `encrypted_pdf`, `unreadable_document`, `too_many_pages` | PDF/PNG/JPEG only, not password-protected, ≤ 300 pages. |
| 413 | `file_too_large` | ≤ 25 MB per file, 60 MB per request. |
| 404 | `not_found` | Wrong ID, or it belongs to another organisation. |
| 409 | `not_draft`, `already_final`, `not_open` | The envelope is not in a state that allows this. |
| 422 | `idempotency_key_reused` | Same `Idempotency-Key` with a different body: use a new key. |
| 429 | `new_org_limit` | New organisations may send a limited number of documents per day during their first days. |
| 404 | `invalid_link` | (signing page) The signing link expired (embedded links live 1 hour) or was revoked: request a new one. |

## 15. Limits

| | |
|---|---|
| Recipients per envelope | 25 |
| Boxes per envelope | 500 |
| File size | 25 MB per file, 60 MB per request |
| Pages | 300 |
| Embedded signing link | valid 1 hour |
| E-mail signing link | valid until the envelope expires |
| New organisations | daily cap on documents and invitations for the first 14 days |
| Webhook timeout | 10 seconds, 12 attempts |

## 16. Going live checklist

- [ ] API key and webhook secret only in server config or a secret store, never in the browser or a mobile app.
- [ ] One key per system, named; you know how to revoke one.
- [ ] `Idempotency-Key` on every create.
- [ ] Webhook signature checked, timestamp checked, `Penly-Event-Id` deduplicated, answer within 10 s.
- [ ] Your record found by `externalRef`; the envelope ID stored.
- [ ] Signed PDF downloaded and stored on `envelope.completed`.
- [ ] Embedded: your origin listed in `embedOrigins`; signing link requested on demand, not stored.
- [ ] A test envelope sent to yourself and signed on a phone.

## 17. FAQ

**Is an electronic signature valid?** In the EU an electronic signature cannot be refused as evidence just because it is electronic (eIDAS, Article 25). PenlyDoc creates electronic signatures with a sealed PDF, timestamp and audit trail; it is not a qualified signature. Some documents legally need a qualified or handwritten signature, so decide per document type. See the [Terms](/terms#signatures).

**Do signers need an account?** No. A link (e-mail) or your app (embed) is enough. Turn on `requireOtp` for an extra e-mail code.

**Can I test without sending real e-mails?** Use addresses you control, or embedded recipients (`"delivery": "EMBEDDED"`), which receive no e-mail at all.

**How do I delete someone's data?** `DELETE /v1/envelopes/{id}` for a document; the owner can close the whole organisation in Settings.

**Where do I see what was sent to my webhook?** Console → Settings → recent deliveries, or `GET /v1/webhook-deliveries`, with status, response code and a resend button.

## 18. API reference

All `/v1` calls need `Authorization: Bearer sk_…`. Full schemas: [/swagger-ui.html](/swagger-ui.html).

| Method | Path | |
|---|---|---|
| GET | `/v1/me` | Your organisation; checks the key. |
| POST | `/v1/envelopes` | Create (multipart `envelope` + `documents`). |
| GET | `/v1/envelopes` | Search by `externalRef`, `status`. |
| GET | `/v1/envelopes/{id}` | Details. |
| DELETE | `/v1/envelopes/{id}` | Delete for good. |
| PUT | `/v1/envelopes/{id}/fields` | Replace boxes of a draft. |
| POST | `/v1/envelopes/{id}/send` · `/remind` · `/void` · `/duplicate` | Lifecycle. |
| POST | `/v1/envelopes/{id}/recipients/{rid}/signing-url` | Embedded signing link (1 h). |
| POST | `/v1/envelopes/{id}/recipients/{rid}/revoke-links` | Invalidate links. |
| GET | `/v1/envelopes/{id}/documents/original` · `/signed` | PDFs. |
| GET | `/v1/envelopes/{id}/audit` | Hash-chained history. |
| POST/GET | `/v1/templates` · `/v1/templates/{id}` | Create (multipart `template` + `documents`), list, get. |
| GET | `/v1/templates/{id}/document` | Template PDF. |
| PUT | `/v1/templates/{id}/fields` | Boxes and grey data boxes. |
| POST | `/v1/templates/{id}/archive` | Hide a template. |
| POST | `/v1/templates/{id}/envelopes` | Send from a template (JSON). |
| GET/PUT | `/v1/webhook` | Webhook URL (PUT returns a new secret). |
| GET | `/v1/webhook-deliveries` | Delivery log. |
| POST | `/v1/webhook-deliveries/test` · `/{id}/retry` | Test event, resend. |
| GET/PUT | `/v1/branding` | Name, colour, default language, embed origins. |
| PUT/GET/DELETE | `/v1/branding/logo` | Logo. |
| GET/PUT | `/v1/settings/reminders` | Organisation default for reminders. |
| POST | `/api/verify` | Public: check a signed PDF. |

Questions or something unclear in this guide: [support@penlydoc.com](mailto:support@penlydoc.com).
