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 returns429 Too Many Requestswith aRetry-Afterheader 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 |
|
Across the worked examples below we will:
Discover the template's
idand the names of its settings.Submit a generation request for a specific Project, overriding
include_inactive_formstotrueandsort_bytoOID.Poll the resulting background task until it completes.
Download the finished
.xlsxfile 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 |
|---|---|---|
|
|
List |
|
|
List |
|
|
Start a background generation, returns a |
|
|
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 |
|---|---|
|
The request body was malformed — for example a missing |
|
Missing or invalid credentials. |
|
You do not have permission to view the URL the template belongs to, or the project the draft belongs to. |
|
The template, draft, project, or task does not exist (or, for the status endpoint, the task does not belong to you). |
|
The template's scope is Project Tickets, which is not currently supported via the API. |