# API reference

> Reference for the DocRelay public API: issuing documents, templates, layouts, completion webhooks, retention periods, tamper detection, scopes and rate limits.

Source: https://docrelay.jp/en/docs/api/

DocRelay is preparing for launch. The specification may change before launch. The base URL is `https://api.docrelay.jp`.

## Issuing documents

Issue documents with `POST /v1/renders`. If the document is ready within `wait` seconds (0-25, default 25), it is returned immediately (`200`). Otherwise you receive `202` with a `Render`, and a webhook on completion. Requests with `wait` set to 0 and batches of more than 50 documents are processed asynchronously.

**curl**

```bash
curl https://api.docrelay.jp/v1/renders \
  -H "Authorization: Bearer $DOCRELAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: inv_88" \
  -d '{
    "template": "invoice",
    "format": "pdf",
    "data": { "customer": { "name": "Acme Inc." }, "total": 128000 },
    "wait": 10
  }' \
  -o invoice.pdf
```

**Node.js**

```js
const res = await fetch('https://api.docrelay.jp/v1/renders', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.DOCRELAY_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'inv_88',
  },
  body: JSON.stringify({
    template: 'invoice',
    format: 'pdf',
    data: { customer: { name: 'Acme Inc.' }, total: 128000 },
    wait: 10,
  }),
});
if (res.status === 200) {
  const pdf = Buffer.from(await res.arrayBuffer()); // x-render-id and x-content-sha256 headers are included
} else if (res.status === 202) {
  const render = await res.json(); // { id: 'rnd_…', status: 'queued', … } → render.succeeded webhook on completion
}
```

| Method | Path | Description |
| --- | --- | --- |
| POST | `/v1/renders` | Issue a document. The document itself (`200`) if ready; a `Render` with `Accept: application/json` or `link`. `202` with a `Render` if not ready |
| GET | `/v1/renders` | Issuance history (filter by `template_id`, `status`, `since`; newest first) |
| GET | `/v1/renders/:id` | One `Render` |
| GET | `/v1/renders/:id/file` | The document. `410 gone` after the retention period, `409` if not yet complete |
| POST | `/v1/renders/:id/links` | Create an expiring URL (`expires_in`, `max_downloads`; defaults to 24 hours with no download limit) |
| GET | `/d/:token` | Download through an expiring URL (no API key). `404` once expired or over the limit, `410` after the document is deleted at the end of its retention period |

**Request and response**

```
type RenderRequest = {
  template: string;          // template ID or name
  version?: number;          // default: published (production) / latest (test)
  format?: 'pdf' | 'xlsx' | 'docx' | 'csv';  // default: the first format
  data?: object;             // one document
  items?: object[];          // 1-1,000 documents, returned as a zip
  merge?: boolean;           // layouts only: merge into one PDF
  wait?: number;             // seconds, 0-25 (default 25)
  keep_for?: number;         // retention period in seconds (default 86,400)
  link?: true | { expires_in?: number; max_downloads?: number };  // expiring URL
  metadata?: Record<string, string>;  // up to 20, also sent in webhooks
  csv?: {                    // how to write CSV (format csv only)
    newline?: 'crlf' | 'lf';  // default crlf
    trailing_newline?: boolean;  // newline after the last row (default true)
    bom?: boolean;           // UTF-8 BOM (default true)
    encoding?: 'utf-8' | 'shift_jis';  // default utf-8
    quote?: 'minimal' | 'all';  // minimal quotes only when needed, all quotes every value
  };
};

type Render = {
  id: string;                // rnd_…
  status: 'queued' | 'rendering' | 'succeeded' | 'failed' | 'expired';
  template_id: string; version: number; format: string;
  filename: string; size: number | null; sha256: string | null; item_count: number;
  error: { code: string; message: string } | null;
  ledger: { seq: number } | null;
  link: { url: string; expires_at: number } | null;
  metadata: Record<string, string>;
  created_at: number; completed_at: number | null; expires_at: number | null;
};
```

- Headers on a direct response: `content-type`, `content-disposition` (non-ASCII file names supported), `x-render-id`, `x-content-sha256`.
- With the optional `Idempotency-Key` header, a retry with the same key within 24 hours returns the same result.
- Request bodies are limited to 5MB.
- Each document issued in production counts toward your monthly volume (batches count each document). Test issuance and previews are not counted.
- Responses include `Server-Timing` with the milliseconds spent in each stage (authentication, template loading, fonts, rendering and more). Send `X-DocRelay-Timing: cpu` to also receive the CPU time of rendering as `cpu`.

## Templates

| Method | Path | Description |
| --- | --- | --- |
| GET | `/v1/templates` | List templates |
| POST | `/v1/templates` | Create a layout template with JSON `{ name, layout? }` (a blank A4 page if `layout` is omitted), or upload a multipart `file` (xlsx or docx) with `name` |
| GET / PATCH / DELETE | `/v1/templates/:id` | Get, update (name, file name, sample data) or delete. `:id` accepts an ID or template name. Issuance history and the tamper-evident log are kept after deletion |
| PUT | `/v1/templates/:id/layout` | Save a layout. Overwrites the latest version if it is a draft; otherwise creates a new draft version |
| POST | `/v1/templates/:id/versions` | Upload an xlsx or docx of the same type to create a new draft version |
| POST | `/v1/templates/:id/versions/:v/publish` | Publish a version. Production API keys use it from then on |
| POST | `/v1/templates/:id/preview` | Preview. Uses the sample data if `data` is omitted. Not recorded in issuance history and not counted toward your monthly volume. Missing merge fields are returned in the `x-missing-fields` header |

## Layouts

Layouts designed in the template editor are JSON documents, saved with `PUT /v1/templates/:id/layout`. Lengths are in millimeters, with the origin at the top left of the page. The MCP tool `get_layout_guide` returns the same guide with samples.

| Element | Description |
| --- | --- |
| `text` | Text with wrapping, shrinking or clipping, borders and fills, `rotate` and `opacity` |
| `line` | Lines (width, color, dashes) |
| `rect` | Rectangles (stroke, fill, rounded corners) |
| `image` | Images (`data:` or https URLs, which can also come from merge fields) |
| `table` | Line-item table. Adds a row per item in a data array and continues onto the next page, repeating the header |
| `flow` | Body text. Starts in its frame and flows onto the following pages. Content is Markdown or data blocks (headings, paragraphs, lists, tables, rules and page breaks) |

### Line-item tables and tables in body text

- `wrap: true` wraps cell text to the column width and sizes each row to its tallest cell. A row that does not fit is split by lines within its cells.
- Row data fields `_span` (span columns), `_align` and `_bold` change the look of individual rows.
- A column’s `rich` field (`[{ text, bold?, color?, underline? }]`) makes part of the text bold, colored or underlined. Markdown body text supports `**bold**` and `<u>underline</u>`.
- Set `headerBold`, `padding` (cell padding) and `lineHeight` (line height within cells).
- Cells of tables in body text can contain paragraphs, lists and tables, nested one level (data blocks only).

### Other options

- `follow: { id, gap? }`: places elements such as totals right after the actual end of a table or body text, moving them to the next page if they do not fit.
- `repeat`: draws elements other than tables and body text on the first page, every page or the last page.
- `watermark: { text, size?, color?, opacity?, angle?, tile?, gap? }`: overlays semi-transparent text on every page. `tile: true` repeats it across the page.
- Merge fields: `{{a.b}}`, `{{x | number}}`, `{{d | date:YYYY-MM-DD}}`, `{{#list}}…{{/list}}`, `{{^x}}…{{/x}}`, `{{_page}}` and `{{_pages}}`.
- Invalid layouts return `422 invalid`, with a description per field such as `elements[3].w` in `fields`.

## Completion webhooks

There are two events: `render.succeeded` and `render.failed`. The body is `{ type, data: Render }`. How to register endpoints will be announced at launch.

**Example body**

```
{
  "type": "render.succeeded",
  "data": {
    "id": "rnd_…", "status": "succeeded", "filename": "invoice_1024.pdf",
    "sha256": "a3f9…", "item_count": 1, "link": null, …
  }
}
```

## Retention period

Set the retention period in seconds with `keep_for`. The default is 86,400 seconds (24 hours), and the maximum is your plan’s retention period. After the retention period, the document is deleted and only the issuance record (hash, size and file name) and the tamper-evident log are kept.

## Tamper-evident log

Tamper detection that applies blockchain technology. Each issued document’s SHA-256, render ID, template, version and time are chained to the previous entry; the data you merge is never recorded. A daily hash (per UTC day) is published.

| Method | Path | Description |
| --- | --- | --- |
| GET | `/v1/ledger/:render_id` | The log entry for a document and the chain up to that day’s daily hash (`head` is `null` until the day is closed) |
| POST | `/v1/ledger/verify` | Send a document file or `{ sha256 }` to verify whether it was issued in this environment |
| GET | `/ledger/heads` | Published daily hashes (no API key; the last 30 days if `date` is omitted) |

```
type LedgerEntry = {
  seq: number;               // sequence number
  render_id: string; sha256: string; template_id: string; version: number;
  at: number;
  prev_hash: string;         // entry_hash of the previous entry
  // SHA-256 of prev_hash, seq, sha256, render_id, at joined by "\n"
  entry_hash: string;
};
type DailyHead = { date: string; seq: number; entry_hash: string; published_at: number };
```

## API key scopes

| Scope | Allows |
| --- | --- |
| `templates:read` | Get and preview templates |
| `templates:write` | Create, update, delete and publish templates |
| `renders:create` | Issue documents |
| `renders:read` | Get issuance history, document files and expiring URLs; get and verify the tamper-evident log |

`GET /v1/whoami` can be called with any scope. It returns the API key’s environment, project and organization.

## Rate limits and errors

- Rate limits apply per environment, and the per-minute limit depends on your plan (Free 60, Starter 300, Pro 1,200, Business 3,000). Every response includes `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset`. Over the limit, the API returns `429 rate_limited` with `retry-after`.
- Errors are returned as `{ error, message, fields?, request_id }`. Input errors return `422 invalid`, with a description per field in `fields`.
- Failed requests can be reviewed in the API logs in the dashboard for 30 days.
