Generating Document Templates via the API

TrialGrid supports user-defined document templates that produce Word, Excel, PDF or text reports for a Draft or a Project. The same generation flow is available over the REST API: callers can list the templates available for a Draft or Project, kick off a generation, poll the resulting background task, and download the finished file from a pre-signed AWS S3 URL.

This page walks through the end-to-end flow with two worked examples — one in Python, one in Node.js — using a fictional Excel template called Project Form Inventory.

Behaviour and constraints

  • API requests run as you. Generating a document via the API is identical to clicking Generate in the UI: the document is created in your user scope, with your permissions, and the resulting file is added to your Documents collection on the homepage. Anything you can run from the UI you can run from the API; anything you cannot run from the UI you cannot run from the API.

  • Generation is asynchronous. The generate endpoint enqueues a background task and returns immediately with a task_id. You poll the task's status endpoint until it reports completion (or failure).

  • Downloads come straight from AWS S3. When the task is complete the status response includes a download URL. This URL is a pre-signed S3 URL — the file is served directly from S3 and never proxied through TrialGrid's own servers. The URL is time-limited: it expires after a short period (typically one hour).

  • The file persists; the URL does not. If your download URL has expired but you still want the same file, simply call the status endpoint again — it will return a freshly-signed URL pointing at the same stored result. The file itself is preserved in your Documents collection until you delete it.

  • Project Tickets templates are not available via the API. Document templates with a scope of Project Tickets cannot currently be generated through this API. Use the TrialGrid UI to run those.

  • The generate endpoint is rate-limited. Each generation kicks off a background task and writes a file to storage, so the POST /document_templates/<id>/generate/ endpoint is capped at 60 requests per hour per user by default (in addition to the global API rate limit that applies to every endpoint). When the cap is reached the API returns 429 Too Many Requests with a Retry-After header indicating how long to wait before retrying. The list, status and download endpoints are not subject to this tighter cap.

Authentication

Document template endpoints accept the same credentials as the rest of the TrialGrid API — see Authentication for full details. The two schemes are:

HTTP Basic Authentication — send your TrialGrid username and password in the standard Authorization header. The server expects a base64-encoded username:password value:

Authorization: Basic YWxpY2VAZXhhbXBsZS5jb206c2VjcmV0LXBhc3N3b3Jk

Token Authentication — obtain a token from the token endpoint (see Token Authentication for how to generate and revoke tokens) and send it in the Authorization header prefixed with Token:

Authorization: Token fa2ae7f1c766ddea1c8b4ff14773de33569d399b

Either scheme works against every endpoint described on this page. The worked examples below show how to set both header styles in code.

Important

Do not send TrialGrid credentials when issuing the final GET against the signed S3 download URL — the signature embedded in the URL is what authorises that request, and adding an unrelated Authorization header can cause the download to fail.

Setting the scene — our worked example

Imagine your administrator has already created the following Excel-type template in TrialGrid:

Attribute

Value

Name

Project Form Inventory

Type

Excel 2003/2004 SpreadsheetML with conversion to XLSX

Scope

Project Home

Settings

  • include_inactive_forms (boolean, default false) — include forms marked inactive

  • sort_by (choice: FormName or OID, default FormName) — how to sort the form list

  • report_title (text, default Form Inventory) — heading shown at the top of the spreadsheet

Across the worked examples below we will:

  1. Discover the template's id and the names of its settings.

  2. Submit a generation request for a specific Project, overriding include_inactive_forms to true and sort_by to OID.

  3. Poll the resulting background task until it completes.

  4. Download the finished .xlsx file from S3.

Endpoint summary

Every endpoint below is versioned with v1 or v2; the examples use v2 but there is no difference.

Method

Path

Purpose

GET

/api/v2/drafts/{draft_id}/document_templates/

List Draft Home-scoped templates available for that draft

GET

/api/v2/projects/{project_id}/document_templates/

List Project Home-scoped templates available for that project

POST

/api/v2/document_templates/{annotate_def_id}/generate/

Start a background generation, returns a task_id

GET

/api/v2/document_template_tasks/{task_id}/

Poll task status; when complete includes a signed S3 download URL

1. Discover the template

Before you can generate anything you need the template's id and the exact names of its settings. List the templates available for the Project (or Draft) you intend to report on:

GET /api/v2/projects/123/document_templates/
Authorization: Token fa2ae7f1c766ddea1c8b4ff14773de33569d399b

(or, equivalently, Authorization: Basic <base64-encoded username:password> — see the Authentication section above.)

Response:

{
    "results": [
        {
            "id": 42,
            "name": "Project Form Inventory",
            "description": "All forms across all drafts in this project.",
            "template_type": "Excel 2003/2004 SpreadsheetML with conversion to XLSX",
            "template_scope": "Project Home",
            "settings": [
                {
                    "name": "include_inactive_forms",
                    "display_name": "Include inactive forms?",
                    "description": "When checked, inactive forms are included in the output.",
                    "setting_type": "boolean",
                    "default_value": "false",
                    "setting_choices": null,
                    "display_order": 0
                },
                {
                    "name": "sort_by",
                    "display_name": "Sort forms by",
                    "description": "Order the form rows in the spreadsheet.",
                    "setting_type": "choice",
                    "default_value": "FormName",
                    "setting_choices": ["FormName", "OID"],
                    "display_order": 1
                },
                {
                    "name": "report_title",
                    "display_name": "Report title",
                    "description": "Heading shown at the top of the spreadsheet.",
                    "setting_type": "text",
                    "default_value": "Form Inventory",
                    "setting_choices": null,
                    "display_order": 2
                }
            ]
        }
    ]
}

The discovery endpoint only lists templates that are active and that match the requested scope. Templates outside your URL are excluded.

Note

template_type and template_scope are returned as the system's stored display strings (e.g. "Excel 2003/2004 SpreadsheetML with conversion to XLSX"). Treat them as opaque labels — match them by equality, don't parse them — they may be reworded between releases.

2. Start a generation

POST to the generate endpoint, supplying the scope-specific id (project_id for a Project Home template, draft_id for a Draft Home template) and a settings object whose keys match the setting names from the discovery response. Any setting you omit falls back to its default.

POST /api/v2/document_templates/42/generate/
Authorization: Token fa2ae7f1c766ddea1c8b4ff14773de33569d399b
Content-Type: application/json

{
    "project_id": 123,
    "settings": {
        "include_inactive_forms": true,
        "sort_by": "OID",
        "report_title": "Q2 form audit"
    }
}

Response (HTTP 202 Accepted):

{
    "task_id": 9876,
    "status_url": "/api/v2/document_template_tasks/9876/"
}

3. Poll the task

The generation runs as a background task. Poll the status_url until state is C (complete) or F (failed):

GET /api/v2/document_template_tasks/9876/
Authorization: Token fa2ae7f1c766ddea1c8b4ff14773de33569d399b

While the task is still running the response will look like (download is null until the task finishes successfully):

{
    "task_id": 9876,
    "name": "Generate Annotate",
    "task_type": "generate_annotate",
    "state": "R",
    "state_display": "Running",
    "progress_pct": 40,
    "state_message": "Generating Excel rows...",
    "created": "2026-05-06T10:00:00+00:00",
    "updated": "2026-05-06T10:00:08+00:00",
    "download": null
}

Once the task completes successfully the download block is populated:

{
    "task_id": 9876,
    "name": "Generate Annotate",
    "task_type": "generate_annotate",
    "state": "C",
    "state_display": "Complete",
    "progress_pct": 100,
    "state_message": "Generation complete",
    "created": "2026-05-06T10:00:00+00:00",
    "updated": "2026-05-06T10:00:23+00:00",
    "download": {
        "url": "https://s3.amazonaws.com/.../annotate.xlsx?X-Amz-Signature=...",
        "filename": "Project Form Inventory.xlsx",
        "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        "size_in_bytes": 18234,
        "expires_in_seconds": 3600
    }
}

State codes are: P Pending, R Running, C Complete, F Failed, X Cancelled.

A reasonable poll cadence is every 2 – 5 seconds; large reports can take several minutes to generate. Note that progress_pct value may not change smoothly on each poll request, do not assume the task has stalled because this value has not changed between poll requests.

4. Download the result

The download.url is a pre-signed AWS S3 URL. Issue an HTTP GET to it (no TrialGrid auth header required — the signature in the URL is what authorises the download) and stream the body to disk. The S3 response sets the correct Content-Type and Content-Disposition: attachment headers based on the template type.

The URL is valid for download.expires_in_seconds seconds (typically 3600). If it expires before you finish, simply call the status endpoint again — every call generates a fresh signed URL pointing at the same stored file. The file itself remains in your Documents collection until you delete it.

Worked example — Python

This example uses requests. Install with pip install requests. Edit the constants at the top for your environment, or set the matching EXAMPLE_* environment variables.

  1# Copyright TrialGrid Limited
  2
  3"""End-to-end example of generating a document template through the TrialGrid API.
  4
  5This script:
  6
  71. Looks up a Project Home document template by name.
  82. Submits a generation request, overriding three of its settings.
  93. Polls the resulting background task until completion (or failure).
 104. Downloads the finished file straight from the pre-signed S3 URL.
 11
 12Edit the ``Configuration`` constants below for your environment, or set the matching
 13``EXAMPLE_*`` environment variables (the test suite uses the env-var path).
 14"""
 15
 16import base64
 17import os
 18import time
 19from pathlib import Path
 20
 21import requests
 22
 23# ---- Configuration ----------------------------------------------------------------------
 24# Edit these constants for your environment, or set the matching environment variables.
 25
 26BASE_URL = os.environ.get("EXAMPLE_BASE_URL", "https://www.trialgrid.io/api/v2")
 27PROJECT_ID = int(os.environ.get("EXAMPLE_PROJECT_ID", "123"))
 28TEMPLATE_NAME = os.environ.get("EXAMPLE_TEMPLATE_NAME", "Project Form Inventory")
 29OUTPUT_DIR = Path(os.environ.get("EXAMPLE_OUTPUT_DIR", ""))
 30
 31# ---- Authentication: pick ONE of the two styles below -----------------------------------
 32# (a) HTTP Basic Authentication — username + password.
 33USERNAME = os.environ.get("EXAMPLE_USERNAME", "alice@example.com")
 34PASSWORD = os.environ.get("EXAMPLE_PASSWORD", "<your password>")
 35AUTH_HEADERS = {
 36    "Authorization": "Basic " + base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode(),
 37}
 38# (b) Token Authentication — see https://www.trialgrid.io/docs/api.html#token-authentication
 39#     for how to generate or revoke tokens.
 40# AUTH_HEADERS = {"Authorization": "Token fa2ae7f1c766ddea1c8b4ff14773de33569d399b"}
 41# -----------------------------------------------------------------------------------------
 42
 43POLL_SECONDS = float(os.environ.get("EXAMPLE_POLL_SECONDS", "3"))
 44TIMEOUT_SECONDS = int(os.environ.get("EXAMPLE_TIMEOUT_SECONDS", "600"))
 45
 46
 47def find_template(project_id: int, name: str) -> dict:
 48    """Look up a Project Home template by name and return its definition."""
 49    resp = requests.get(
 50        f"{BASE_URL}/projects/{project_id}/document_templates/",
 51        headers=AUTH_HEADERS,
 52        timeout=30,
 53    )
 54    resp.raise_for_status()
 55    for template in resp.json()["results"]:
 56        if template["name"] == name:
 57            return template
 58    raise LookupError(f"No template named {name!r} on project {project_id}")
 59
 60
 61def start_generation(template_id: int, project_id: int, settings: dict) -> int:
 62    """Kick off a generation. Returns the task_id."""
 63    resp = requests.post(
 64        f"{BASE_URL}/document_templates/{template_id}/generate/",
 65        json={"project_id": project_id, "settings": settings},
 66        headers=AUTH_HEADERS,
 67        timeout=30,
 68    )
 69    resp.raise_for_status()
 70    return resp.json()["task_id"]
 71
 72
 73def wait_for_completion(
 74    task_id: int, poll_seconds: float = POLL_SECONDS, timeout_seconds: int = TIMEOUT_SECONDS
 75) -> dict:
 76    """Poll until the task is Complete or Failed. Returns the final status payload."""
 77    deadline = time.monotonic() + timeout_seconds
 78    while True:
 79        resp = requests.get(
 80            f"{BASE_URL}/document_template_tasks/{task_id}/",
 81            headers=AUTH_HEADERS,
 82            timeout=30,
 83        )
 84        resp.raise_for_status()
 85        status = resp.json()
 86        state = status["state"]
 87        if state == "C":
 88            return status
 89        if state == "F":
 90            raise RuntimeError(f"Generation failed: {status['state_message']}")
 91        if time.monotonic() > deadline:
 92            raise TimeoutError(f"Task {task_id} did not complete in {timeout_seconds}s")
 93        print(f"  state={state} progress={status['progress_pct']}% — {status['state_message']}")
 94        time.sleep(poll_seconds)
 95
 96
 97def download(status: dict, save_to: Path) -> None:
 98    """Stream the file to disk from the signed S3 URL.
 99
100    The S3 URL is time-limited (see ``download.expires_in_seconds``). If it has expired,
101    re-poll ``document_template_tasks/{task_id}/`` to get a fresh URL for the same file.
102    """
103    download_info = status["download"]
104    # Note: do NOT send AUTH_HEADERS here — the signed URL authorises the request, and
105    # adding an unrelated Authorization header can cause S3 to reject the download.
106    with requests.get(download_info["url"], stream=True, timeout=60) as resp:
107        resp.raise_for_status()
108        with save_to.open("wb") as fh:
109            for chunk in resp.iter_content(chunk_size=64 * 1024):
110                fh.write(chunk)
111    print(f"Saved {download_info['size_in_bytes']} bytes to {save_to}")
112
113
114if __name__ == "__main__":
115    template = find_template(PROJECT_ID, TEMPLATE_NAME)
116    print(f"Found template id={template['id']} ({template['template_type']})")
117
118    task_id = start_generation(
119        template_id=template["id"],
120        project_id=PROJECT_ID,
121        settings={
122            "include_inactive_forms": True,
123            "sort_by": "OID",
124            "report_title": "Q2 form audit",
125        },
126    )
127    print(f"Started task {task_id}")
128
129    final = wait_for_completion(task_id)
130    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
131    download(final, OUTPUT_DIR / final["download"]["filename"])

Worked example — Node.js

This example uses Node.js 18+ (which has fetch built in) and the standard library only.

  1// Copyright TrialGrid Limited
  2//
  3// End-to-end example of generating a document template through the TrialGrid API.
  4//
  5// This script:
  6//  1. Looks up a Project Home document template by name.
  7//  2. Submits a generation request, overriding three of its settings.
  8//  3. Polls the resulting background task until completion (or failure).
  9//  4. Downloads the finished file straight from the pre-signed S3 URL.
 10//
 11// Edit the Configuration constants below for your environment, or set the matching
 12// EXAMPLE_* environment variables (the test suite uses the env-var path).
 13//
 14// Requires Node.js 18+ for built-in fetch.
 15
 16import { writeFile, mkdir } from "node:fs/promises";
 17import { Buffer } from "node:buffer";
 18import { join } from "node:path";
 19
 20// ---- Configuration ----------------------------------------------------------------------
 21// Edit these constants for your environment, or set the matching environment variables.
 22
 23const BASE_URL = process.env.EXAMPLE_BASE_URL ?? "https://www.trialgrid.io/api/v2";
 24const PROJECT_ID = parseInt(process.env.EXAMPLE_PROJECT_ID ?? "123", 10);
 25const TEMPLATE_NAME = process.env.EXAMPLE_TEMPLATE_NAME ?? "Project Form Inventory";
 26const OUTPUT_DIR = process.env.EXAMPLE_OUTPUT_DIR ?? ".";
 27
 28// ---- Authentication: pick ONE of the two styles below -----------------------------------
 29// (a) HTTP Basic Authentication — username + password.
 30const USERNAME = process.env.EXAMPLE_USERNAME ?? "alice@example.com";
 31const PASSWORD = process.env.EXAMPLE_PASSWORD ?? "<your password>";
 32const AUTH_HEADERS = {
 33    Authorization: "Basic " + Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64"),
 34};
 35// (b) Token Authentication — see https://www.trialgrid.io/docs/api.html#token-authentication
 36//     for how to generate or revoke tokens.
 37// const AUTH_HEADERS = { Authorization: "Token fa2ae7f1c766ddea1c8b4ff14773de33569d399b" };
 38// -----------------------------------------------------------------------------------------
 39
 40const POLL_MS = parseInt(process.env.EXAMPLE_POLL_MS ?? "3000", 10);
 41const TIMEOUT_MS = parseInt(process.env.EXAMPLE_TIMEOUT_MS ?? "600000", 10);
 42
 43async function findTemplate(projectId, name) {
 44    const resp = await fetch(`${BASE_URL}/projects/${projectId}/document_templates/`, {
 45        headers: AUTH_HEADERS,
 46    });
 47    if (!resp.ok) throw new Error(`Discovery failed: ${resp.status} ${await resp.text()}`);
 48    const body = await resp.json();
 49    const template = body.results.find((t) => t.name === name);
 50    if (!template) throw new Error(`No template named "${name}" on project ${projectId}`);
 51    return template;
 52}
 53
 54async function startGeneration(templateId, projectId, settings) {
 55    const resp = await fetch(`${BASE_URL}/document_templates/${templateId}/generate/`, {
 56        method: "POST",
 57        headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
 58        body: JSON.stringify({ project_id: projectId, settings }),
 59    });
 60    if (!resp.ok) throw new Error(`Generate failed: ${resp.status} ${await resp.text()}`);
 61    const body = await resp.json();
 62    return body.task_id;
 63}
 64
 65function sleep(ms) {
 66    return new Promise((resolve) => setTimeout(resolve, ms));
 67}
 68
 69async function waitForCompletion(taskId, { pollMs = POLL_MS, timeoutMs = TIMEOUT_MS } = {}) {
 70    const deadline = Date.now() + timeoutMs;
 71    while (true) {
 72        const resp = await fetch(`${BASE_URL}/document_template_tasks/${taskId}/`, {
 73            headers: AUTH_HEADERS,
 74        });
 75        if (!resp.ok) throw new Error(`Status failed: ${resp.status} ${await resp.text()}`);
 76        const status = await resp.json();
 77        if (status.state === "C") return status;
 78        if (status.state === "F") throw new Error(`Generation failed: ${status.state_message}`);
 79        if (Date.now() > deadline) throw new Error(`Task ${taskId} timed out`);
 80        console.log(
 81            `  state=${status.state} progress=${status.progress_pct}% — ${status.state_message}`,
 82        );
 83        await sleep(pollMs);
 84    }
 85}
 86
 87async function download(status, savePath) {
 88    // The S3 URL is time-limited (see download.expires_in_seconds). If it has expired,
 89    // re-poll document_template_tasks/{taskId}/ to get a fresh URL for the same file.
 90    const { url, size_in_bytes } = status.download;
 91    // Note: do NOT send AUTH_HEADERS here — the signed URL authorises the request, and
 92    // adding an unrelated Authorization header can cause S3 to reject the download.
 93    const resp = await fetch(url);
 94    if (!resp.ok) throw new Error(`Download failed: ${resp.status} ${await resp.text()}`);
 95    const buffer = Buffer.from(await resp.arrayBuffer());
 96    await writeFile(savePath, buffer);
 97    console.log(`Saved ${size_in_bytes} bytes to ${savePath}`);
 98}
 99
100(async () => {
101    const template = await findTemplate(PROJECT_ID, TEMPLATE_NAME);
102    console.log(`Found template id=${template.id} (${template.template_type})`);
103
104    const taskId = await startGeneration(template.id, PROJECT_ID, {
105        include_inactive_forms: true,
106        sort_by: "OID",
107        report_title: "Q2 form audit",
108    });
109    console.log(`Started task ${taskId}`);
110
111    const final = await waitForCompletion(taskId);
112    await mkdir(OUTPUT_DIR, { recursive: true });
113    await download(final, join(OUTPUT_DIR, final.download.filename));
114})().catch((err) => {
115    console.error(err);
116    process.exit(1);
117});

Error responses

Status

Meaning

400

The request body was malformed — for example a missing draft_id / project_id, an invalid setting value, or a draft / project that doesn't belong to the template's URL.

401

Missing or invalid credentials.

403

You do not have permission to view the URL the template belongs to, or the project the draft belongs to.

404

The template, draft, project, or task does not exist (or, for the status endpoint, the task does not belong to you).

405

The template's scope is Project Tickets, which is not currently supported via the API.