# Fintrixor eSign — REST API

Programmatic access to templates, documents, signing links, certificates and webhooks.

- **Base URL:** `{APP_URL}/api` (e.g. `https://esign.example.com/api`)
- **Format:** JSON. Send `Accept: application/json`. Bodies are JSON (`Content-Type: application/json`) except template upload, which is `multipart/form-data`.
- **Auth:** Bearer token (Laravel Sanctum) on every endpoint except the public verify endpoint.

```
Authorization: Bearer esign_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

Create a token in the dashboard (**Settings → API Keys**) or via `POST /api/api-keys`.

---

## Conventions

- All resources are scoped to the authenticated user. Accessing another user's resource returns **404**.
- Timestamps are ISO-8601 UTC (`2026-06-24T12:00:43+00:00`).
- IDs are ULIDs (26 chars).
- Responses are **not** wrapped in a `data` envelope for single resources; list endpoints are paginated (`data`, `links`, `meta`).

### Errors

| Status | Meaning |
|---|---|
| `401` | Missing/invalid bearer token |
| `404` | Not found or not owned by you |
| `422` | Validation failed |
| `207` | Multi-status (batch — see per-item `status`) |

Validation error shape:

```json
{
  "message": "The recipient email field is required.",
  "errors": { "recipient_email": ["The recipient email field is required."] }
}
```

---

## Templates

A template is a PDF plus its field layout. Fields use **normalized coordinates** in `[0,1]` (fractions of the page, top-left origin).

Field `type`: `signature`, `text`, `date`, `id_number`, `file_upload`, `checkbox`, `label`.

### `GET /api/templates`
List your templates (paginated).

### `POST /api/templates`
Create a template (`multipart/form-data`).

| Field | Type | Notes |
|---|---|---|
| `name` | string | required |
| `pdf` | file | required, `application/pdf`, ≤ `ESIGN_MAX_PDF_MB` |
| `fields` | array | optional; each item below |
| `fields[].type` | enum | required |
| `fields[].label` | string | required |
| `fields[].variable_name` | string | optional (auto-derived from label; merge key) |
| `fields[].required` | bool | optional |
| `fields[].page` | int | required, 1-based |
| `fields[].pos_x` `pos_y` `width` `height` | float | required, `0..1` |

```bash
curl -X POST {APP_URL}/api/templates \
  -H "Authorization: Bearer $TOKEN" \
  -F "name=Engagement Letter" \
  -F "pdf=@./engagement.pdf" \
  -F "fields[0][type]=text"      -F "fields[0][label]=Client name" \
  -F "fields[0][variable_name]=client_name" -F "fields[0][required]=1" \
  -F "fields[0][page]=1" -F "fields[0][pos_x]=0.12" -F "fields[0][pos_y]=0.30" \
  -F "fields[0][width]=0.40" -F "fields[0][height]=0.03" \
  -F "fields[1][type]=signature" -F "fields[1][label]=Signature" \
  -F "fields[1][required]=1" -F "fields[1][page]=1" \
  -F "fields[1][pos_x]=0.12" -F "fields[1][pos_y]=0.55" \
  -F "fields[1][width]=0.35" -F "fields[1][height]=0.09"
```

**201** → a `Template` object:

```json
{
  "id": "01KVW...",
  "name": "Engagement Letter",
  "page_count": 1,
  "variables": ["client_name"],
  "status": "ready",
  "fields": [
    { "id": "01KVW...", "type": "text", "label": "Client name",
      "variable_name": "client_name", "required": true, "page": 1,
      "position": { "x": 0.12, "y": 0.30, "width": 0.40, "height": 0.03 }, "font_size": 12 }
  ],
  "created_at": "2026-06-24T12:00:00+00:00",
  "updated_at": "2026-06-24T12:00:00+00:00"
}
```

### `GET /api/templates/{id}`
Retrieve a template (with fields).

### `PUT /api/templates/{id}`
Update `name` and/or replace `fields` (same field schema as create; no PDF). Replacing `fields` re-derives `variables`.

### `DELETE /api/templates/{id}`
Delete a template (**204**). Documents already created from it are retained.

---

## Signing Requests

A **signing request** is an *envelope*: it bundles one or more contracts (a base document plus any number of annexes) for a **single recipient** behind **one signing link**. Each contract in the envelope is still built from a template and becomes its own `Document` with its own fields, signed PDF and certificate — they simply share one recipient, one token, one expiry, and one aggregate lifecycle.

Use this instead of `POST /api/documents/create` when the recipient should sign several documents (e.g. an agreement + its annexes) in a single session.

### `POST /api/requests`

Create a request and (by default) issue its signing link.

| Field | Type | Notes |
|---|---|---|
| `recipient_name` | string | required |
| `recipient_email` | string | required, email |
| `templates` | array | required, **1–25** items — the documents to bundle, in order |
| `templates[].template_id` | string | required, must exist and be owned by you |
| `templates[].variables` | object | optional, `{ variable_name: value }` — **independent per item** (the same `template_id` may appear more than once with different data) |
| `auto_send` | bool | default `true` — issue the link + mark `sent` |
| `send_email` | bool | default `false` — also email the link from the platform |

```bash
curl -X POST {APP_URL}/api/requests \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "recipient_name": "John Smith",
    "recipient_email": "john@example.com",
    "templates": [
      { "template_id": "01BASE...",  "variables": { "client_name": "John Smith" } },
      { "template_id": "01ANNEX...", "variables": { "care_setting": "Setting 1" } },
      { "template_id": "01ANNEX...", "variables": { "care_setting": "Setting 2" } }
    ]
  }'
```

**201** → a `SigningRequest` object:

```json
{
  "id": "01KVW...",
  "status": "sent",
  "recipient_name": "John Smith",
  "recipient_email": "john@example.com",
  "signing_url": "{APP_URL}/sign/01KVW.../<token>?expires=...&signature=...",
  "expires_at": "2026-07-08T12:00:43+00:00",
  "created_at": "2026-06-24T12:00:43+00:00",
  "sent_at": "2026-06-24T12:00:43+00:00",
  "signed_at": null,
  "completed_at": null,
  "documents": [
    {
      "id": "01KVW...",
      "status": "sent",
      "signing_request_id": "01KVW...",
      "template_id": "01BASE...",
      "recipient_name": "John Smith",
      "recipient_email": "john@example.com",
      "variables": { "client_name": "John Smith" },
      "signing_url": "{APP_URL}/sign/01KVW.../<token>?expires=...&signature=...",
      "draft_url": "{APP_URL}/documents/01KVW.../draft.pdf?expires=...&signature=...",
      "expires_at": "2026-07-08T12:00:43+00:00",
      "document_hash": null,
      "certificate_id": null,
      "download_url": null,
      "created_at": "2026-06-24T12:00:43+00:00",
      "sent_at": "2026-06-24T12:00:43+00:00",
      "signed_at": null,
      "completed_at": null
    }
  ]
}
```

- `documents[]` are returned **in the same order** as the `templates` you sent.
- The single `signing_url` lives on the request; each document echoes it once sent. Deliver it to the recipient yourself (or set `send_email: true`). It is `null` while the request is a `draft`.
- Each document carries its own `draft_url` — a temporary signed **PDF preview** (no login) of that contract with its merged data but no signature.

**Draft-first flow.** Send `auto_send: false` to create the request **without** issuing a link: `status` is `draft`, `signing_url` is `null`, but every `documents[].draft_url` is live so you can preview each contract before committing. Then call `POST /api/requests/send` to issue the one link.

### `POST /api/requests/send`

Issue (or re-issue, e.g. after expiry) the request's single signing link and mark every contract in it `sent`.

| Field | Type | Notes |
|---|---|---|
| `signing_request_id` | string | required |
| `send_email` | bool | default `false` |

Returns the updated `SigningRequest` (with a fresh `signing_url` and its `documents`).

### `GET /api/requests/{id}`

Retrieve a request with its `documents` (each including `fields`).

### Request status lifecycle

Same lifecycle as a document (`draft → sent → opened → viewed → signed → completed`, plus `expired`), aggregated across the bundle: the request advances as the recipient works through it and completes once every contract is signed. Each contract is still hashed and certified individually — download the signed PDF / certificate per document via the [Documents](#documents) endpoints.

---

## Documents

Creating a document snapshots the template's fields, merges your `variables` into matching non-signature fields, and (by default) issues a **signing link** you deliver to the recipient yourself.

### `POST /api/documents/create`

| Field | Type | Notes |
|---|---|---|
| `template_id` | string | required |
| `recipient_name` | string | required |
| `recipient_email` | string | required, email |
| `variables` | object | optional, `{ variable_name: value }` |
| `auto_send` | bool | default `true` — issue the link + mark `sent` |
| `send_email` | bool | default `false` — also email the link from the platform |

```bash
curl -X POST {APP_URL}/api/documents/create \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "template_id": "01KVW...",
    "recipient_name": "John Smith",
    "recipient_email": "john@example.com",
    "variables": { "client_name": "John Smith" }
  }'
```

**201** → a `Document` object:

```json
{
  "id": "01KVW...",
  "status": "sent",
  "template_id": "01KVW...",
  "recipient_name": "John Smith",
  "recipient_email": "john@example.com",
  "variables": { "client_name": "John Smith" },
  "signing_url": "{APP_URL}/sign/01KVW.../<token>?expires=...&signature=...",
  "draft_url": "{APP_URL}/documents/01KVW.../draft.pdf?expires=...&signature=...",
  "expires_at": "2026-07-08T12:00:43+00:00",
  "document_hash": null,
  "certificate_id": null,
  "download_url": null,
  "created_at": "2026-06-24T12:00:43+00:00",
  "sent_at": "2026-06-24T12:00:43+00:00",
  "signed_at": null,
  "completed_at": null
}
```

Deliver `signing_url` to the recipient via your own email/SMS. It is valid until `expires_at`.

`draft_url` renders a **PDF preview** of the document with your merged data filled in but **without a signature** — useful to confirm the merge looks right before sending. It is a temporary signed link (no login required), valid until the same expiry, and present on every document (create, batch, send, show).

### `POST /api/documents/batch`

Create many documents in one call. A top-level `template_id` / `send_email` act as defaults each item may override. **Partial success** is allowed — one bad row does not abort the rest (HTTP **207**).

```bash
curl -X POST {APP_URL}/api/documents/batch \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "template_id": "01KVW...",
    "documents": [
      { "recipient_name": "A", "recipient_email": "a@example.com", "variables": { "client_name": "A" } },
      { "recipient_name": "B", "recipient_email": "b@example.com" }
    ]
  }'
```

**207**:

```json
{
  "results": [
    { "index": 0, "status": "created", "document": { "id": "01KVW...", "signing_url": "...", "...": "..." } },
    { "index": 1, "status": "error", "recipient_email": "b@example.com", "message": "A valid template is required." }
  ]
}
```

### `POST /api/documents/send`
Re-issue the signing link (e.g. after expiry) and/or email it.

| Field | Type | Notes |
|---|---|---|
| `document_id` | string | required |
| `send_email` | bool | default `false` |

Returns the updated `Document` (with a fresh `signing_url`).

### `GET /api/documents/{id}`
Retrieve a document (with fields + certificate id).

### `GET /api/documents/{id}/download`
Download the **signed PDF** (`application/pdf`). **404** until completed.

### `GET /api/documents/{id}/certificate`
Download the **Certificate of Authenticity** PDF. **404** until completed.

### Document status lifecycle

`draft → sent → opened → viewed → signed → completed` (plus `expired`). The recipient must open and scroll through the document (`viewed`), confirm a legally-binding consent (logged with timestamp + IP), and sign. On completion the platform stamps the values + signature, SHA-256 hashes the flattened PDF, issues the certificate, and emails the signed copy + certificate to the recipient.

---

## API keys

### `GET /api/api-keys`
List your tokens (id, name, last_used_at, created_at).

### `POST /api/api-keys`
Create a token. The plain-text key is returned **once**.

```json
{ "id": 12, "name": "Production server", "token": "12|abcdef..." }
```

### `DELETE /api/api-keys/{id}`
Revoke a token (**204**).

---

## Webhooks

### `GET /api/webhooks` · `POST /api/webhooks` · `GET|PUT|DELETE /api/webhooks/{id}`

Create:

| Field | Type | Notes |
|---|---|---|
| `url` | string | required, https URL |
| `events` | array | required; values below, or `["*"]` for all |
| `active` | bool | optional, default `true` |

Events: `document.created`, `document.sent`, `document.viewed`, `document.signed`, `document.completed`, `certificate.generated`.

**201** returns the endpoint including a **`secret`** (shown once) — store it to verify signatures.

### Delivery

Each event POSTs JSON to your `url` with headers:

| Header | Value |
|---|---|
| `X-Esign-Signature` | `sha256=<hmac>` |
| `X-Esign-Event` | e.g. `document.completed` |
| `X-Esign-Delivery` | delivery id |

Payload:

```json
{
  "id": "01KVW...",
  "event": "document.completed",
  "created_at": "2026-06-24T12:00:43+00:00",
  "data": {
    "document": {
      "id": "01KVW...",
      "status": "completed",
      "recipient_name": "John Smith",
      "recipient_email": "john@example.com",
      "document_hash": "f40efc31...",
      "sent_at": "...", "signed_at": "...", "completed_at": "...",
      "certificate_id": "01KVW...",
      "verify_url": "{APP_URL}/verify/01KVW..."
    }
  }
}
```

Retries: up to 6 attempts with exponential backoff (`10s, 60s, 5m, 30m, 2h`) on any non-2xx response.

### Verifying the signature

Compute the HMAC over the **raw request body** with your endpoint secret and compare (constant-time) to the `X-Esign-Signature` header.

```php
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $endpointSecret);
if (! hash_equals($expected, $request->header('X-Esign-Signature'))) {
    abort(401);
}
```

```js
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.header('X-Esign-Signature')));
```

---

## Verification (public, no auth)

### `GET /api/verify/{certificate_id}`

```json
{
  "verified": true,
  "status": "completed",
  "certificate_id": "01KVW...",
  "document_id": "01KVW...",
  "signer": { "name": "John Smith", "email": "john@example.com" },
  "signed_at": "2026-06-24T12:00:43+00:00",
  "document_hash": "f40efc31...",
  "issued_at": "2026-06-24T12:00:43+00:00",
  "verify_url": "{APP_URL}/verify/01KVW..."
}
```

**404** if the certificate id is unknown. The same record is shown on the public page `{APP_URL}/verify/{certificate_id}`.

---

## Endpoint summary

| Method | Path | Auth |
|---|---|---|
| GET/POST | `/api/templates` | bearer |
| GET/PUT/DELETE | `/api/templates/{id}` | bearer |
| POST | `/api/requests` | bearer |
| POST | `/api/requests/send` | bearer |
| GET | `/api/requests/{id}` | bearer |
| POST | `/api/documents/create` | bearer |
| POST | `/api/documents/batch` | bearer |
| POST | `/api/documents/send` | bearer |
| GET | `/api/documents/{id}` | bearer |
| GET | `/api/documents/{id}/download` | bearer |
| GET | `/api/documents/{id}/certificate` | bearer |
| GET/POST | `/api/api-keys` | bearer |
| DELETE | `/api/api-keys/{id}` | bearer |
| GET/POST | `/api/webhooks` | bearer |
| GET/PUT/DELETE | `/api/webhooks/{id}` | bearer |
| GET | `/api/verify/{certificate_id}` | public |
