curl --request POST \
--url https://app.vumasign.com/api/v1/templates \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"file": "JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=",
"roles": [
"Employee"
]
}
'import requests
url = "https://app.vumasign.com/api/v1/templates"
payload = {
"file": "JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=",
"roles": ["Employee"]
}
headers = {
"Idempotency-Key": "<idempotency-key>",
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
'Idempotency-Key': '<idempotency-key>',
Authorization: 'Bearer <token>',
'Content-Type': 'application/json'
},
body: JSON.stringify({
file: 'JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=',
roles: ['Employee']
})
};
fetch('https://app.vumasign.com/api/v1/templates', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.vumasign.com/api/v1/templates"
payload := strings.NewReader("{\n \"file\": \"JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=\",\n \"roles\": [\n \"Employee\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Idempotency-Key", "<idempotency-key>")
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}{
"id": "01960000-0000-4000-8000-0000000007e1",
"name": "Employment contract",
"created_at": "2026-09-01T08:30:00.000Z",
"documents": [
{
"id": "01960000-0000-4000-8000-00000000d0c5",
"page_count": 1
}
],
"pages": [
{
"document_id": "01960000-0000-4000-8000-00000000d0c5",
"page": 1,
"width": 595.28,
"height": 841.89,
"rotation": 0
}
],
"roles": [
{
"name": "Employee",
"routing_type": "sign"
}
],
"subjects": [
{
"key": "employee",
"label": "Employee"
}
],
"questions": [],
"fields": [
{
"id": "01960000-0000-4000-8000-00000000f1e1",
"type": "text",
"label": "Full name",
"required": true,
"role": "Employee",
"document_id": "01960000-0000-4000-8000-00000000d0c5",
"page": 1,
"subject": "employee",
"data_key": "full_name",
"question_id": null,
"options": null,
"rect": {
"x": 0.1007929,
"y": 0.2107045,
"w": 0.5288973,
"h": 0.0213805
}
}
]
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}Upload a PDF or a Word document and get a template with its fields already placed.
THE ENDPOINT THIS API EXISTS FOR. Send a document and a list of roles; get back a template whose signature boxes, date fields, ID-number combs and marital-status questions are already on the page, each carrying the id you need to address it. Nobody opens an editor.
⚠️ FIRST, CHECK THIS IS THE RESOURCE YOU WANT. A template is a LIBRARY ENTRY — a kind of document you will send again, which stays in GET /api/v1/templates and on the Templates screen until it is archived. If what you are sending exists ONCE — an offer of employment to one candidate, a Letter of Authority — you want POST /api/v1/envelopes/one-off instead. It takes the same document and the SAME TEXT TAGS, does the same detection, sends the same envelope, and creates no template: template_id comes back null.
⚠️ THIS FORK WAS NOT WRITTEN DOWN HERE AND IT COST A CUSTOMER A ROW PER SEND. The one-off endpoint explains itself perfectly and an integrator reading THIS page had no reason to go and find it — so a product owner sent one offer letter and watched it appear in his template library. The paragraph below about documents that differ per employee is what pointed them here, and it was right about the TAGS and silent about the RESOURCE.
⚠️ TWO WAYS FIELDS GET PLACED, AND THE DOCUMENT CHOOSES. If your document carries TEXT TAGS, they win and detection does not run. Otherwise the page is measured.
Text tags — write the field into the document where it goes, and it arrives placed and already assigned to the right signer:
<<sig:Main Member>> a signature for the role named
<<text:Witness:Home address>> a labelled text box
<<title:Main Member>> the signer’s job title, prefilled
<<datesigned:Main Member>> the date they signed, stamped
<<text:Witness:optional>> a box they may leave empty
<<text:Witness:validate(email)>> checked as an email address
<<sig:Main Member:dimension(60x15mm)>> say the size, do not pad the tag
<<text:Employee:key(employee_id)>> the api name a value addresses
Types: sig, signature, initial, text, dateinput, check, checkbox, title, company, name, email, datesigned. <<sig:Main Member>> names the role EXACTLY and case-sensitively; <<sig:signerN>> names it BY POSITION — signer1 is the first entry in your roles array — for a generator that does not know your roster. SEVERAL DIRECTIVES may follow the role, in any order: optional, validate(<type>), dimension(WxH mm|pt) and key(<name>) — which sets the field’s data_key, stored lower-cased, so address it in lower case when you prefill. A segment shaped like name(...) is never a label: it is a directive this parser knows, or the whole request is refused. The first plain word or phrase becomes the field’s label, which is why a field labelled literally “optional” cannot be expressed by a tag. What a tag still cannot set is a comb, a per-character input shape, a condition, a dropdown’s options or a validate(regex) pattern — a pattern would have to survive being typed into a document — so those belong to a field you edit in the editor afterwards.
⚠️ TAGS ARE WHAT YOU WANT WHEN EVERY DOCUMENT DIFFERS. A contract generated per employee — conditional clauses, a page count that varies — cannot reuse one template and has nothing detectable on it either. Tags travel with the text, so they land correctly however the document reflows.
⚠️ BUT THAT SAYS NOTHING ABOUT WHICH ENDPOINT, and this paragraph used to be read as though it did. POST /api/v1/envelopes/one-off accepts the identical tags. If each generated contract goes to one person and is never sent again, send it THERE: every one you create here is a permanent library row, and a hundred offers of employment is a hundred of them. Create a template here when the document is a KIND you will send repeatedly — even if its text is merged per recipient.
⚠️ A TAG THAT CANNOT BE PLACED REFUSES THE WHOLE REQUEST with 400 invalid_request, naming every bad tag and its page. A tag naming a role you did not declare is the common one. Nothing is created — silently dropping the tag would lose a signature field, and leaving it as text would print <<sig:Main Member>> on a document somebody signs.
⚠️ WHO A FIELD IS ABOUT — subject(dependant_2). Declare the people the document asks about in subjects, then address a field to one of them. A field addressed to somebody who is not on a given envelope is hidden and not required, automatically, with no condition to write:
<<text:Employee:subject(dependant_2):Surname>>
The argument is the subject’s KEY, not its label — the key is stable and the label is prose somebody may rename. An undeclared key refuses the whole request, naming the ones the document does declare, exactly as an unknown role does.
⚠️ AND THE SUBJECT MUST BE conditional FOR ANY OF IT TO MATTER. A required subject is on every envelope and can never be absent, which is the safe default; conditional is the one the caller or the signer decides about. A medical-aid application declaring spouse and dependant_1..3 as conditional asks for none of them until somebody says that person exists — which is the whole point.
⚠️ WHERE THE FIELD LANDS, which this reference did not say until a customer had to work it out from a screenshot. The field’s LEFT EDGE is where the tag starts, and the field is centred vertically on the tag’s line — so it straddles the line you typed on, reaching about half its height above and half below the middle of the tag’s text, whatever type it is. Its HEIGHT comes from its type. Its WIDTH is the tag’s own printed width or the type’s minimum, whichever is larger. dimension() replaces both, and is placed the same way.
⚠️ SO WRITE THE TAG ON THE LINE ABOVE A RULE, not on the rule and not under it. Signature blocks are drawn with underscores, and there is nowhere on a row of underscores to put a tag without breaking the rule you drew:
<<sig:Employee>> <<datesigned:Employee>>
______________________ ______________________
Employee name Date
The signature, 40pt tall, then straddles its rule. A shorter field — a date, a text box, a signed date — is centred on the same line but reaches less far, so at ordinary line spacing it sits just above its rule rather than across it; dimension() sets an exact size when you want it to reach the rule. A tag on the row BELOW a rule puts the field below it, beside whatever caption is there — which is a common way to end up with a date under the line instead of on it.
Sizes by tag: sig or signature 40pt tall, at least 120pt wide; initial 40pt tall, at least 40pt wide; dateinput 18pt tall, at least 80pt wide; text 18pt tall, at least 80pt wide; check or checkbox 14pt tall, at least 14pt wide; title 15.2pt tall, at least 131pt wide; company 15.2pt tall, at least 131pt wide; name 15.2pt tall, at least 131pt wide; email 15.2pt tall, at least 166.6pt wide; datesigned 15.2pt tall, at least 89pt wide.
⚠️ AND THE TAG STAYS VISIBLE unless you write it in white. Nothing here rewrites your PDF, so a tag in black ink prints on the sealed document. Extraction finds white text perfectly well.
⚠️ AND THE DELIMITER IS << >> BECAUSE YOUR DOCUMENT IS PROBABLY GENERATED. Every {-based templating engine destroys a {{ }} tag before we ever see the document — Mustache and Handlebars delete it SILENTLY, so the upload succeeds and reports no tags, and docxtemplater throws. << >> is untouched by all three, so your merge fields ({employee.full_name}, {#clause}…{/}) and your tags coexist with nothing to reconfigure. A {{type:Role}} tag is refused by name rather than ignored, as is Dropbox Sign’s [sig|req|signer1], so neither can be uploaded silently and printed on a signed document.
✅ AND YOU NO LONGER HAVE TO RENDER IT TO PDF YOURSELF. file takes a Word .docx as well as a PDF — decided by the BYTES, so there is no content_type to set and nothing to rename — and a docxtemplater or Word pipeline therefore has one step FEWER than it used to: write the tags into the document and upload the .docx. We render it, and the tags survive that render, which is the whole reason they are text.
A Word document (.docx) is accepted and converted to PDF on our side. The template is over the converted PDF: that is what page numbers, field coordinates and every later GET refer to, and it is what a signer sees. The .docx you sent is retained unchanged and is not what anybody signs.
⚠️ THE OLDER .doc IS NOT THE SAME FORMAT AND IS NOT ACCEPTED — it is refused by its bytes, with a sentence saying to Save As a .docx.
Detection, when there are no tags, is the same code the editor’s “Find fields” button runs, not a second implementation — so a template authored here and one authored by a person pressing that button have their fields in the same places, with the same masks and the same inferred types, because it is the same function. It infers position but not ownership, so every detected field goes to your first non-cc role, for you to reassign.
⚠️ A DOCUMENT WITH NOTHING DETECTABLE IN IT IS NOT AN ERROR. A flat scan with no text layer yields no fields, and this still answers 201 with a template holding its roles, its document and no fields — which is the thing you then place fields on. fields being empty is how you tell.
The document travels base64-encoded inside the JSON body rather than as multipart/form-data, so one media type and one HTTP client serve every endpoint of this API. See file for what that costs and what it bounds.
A vsk_test_ key may author templates, deliberately: a template is a draft, nothing reaches a signer until an envelope is created AND sent, and iterating on where the boxes land is what a sandbox is for. Templates carry no sandbox/live distinction of their own.
curl --request POST \
--url https://app.vumasign.com/api/v1/templates \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"file": "JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=",
"roles": [
"Employee"
]
}
'import requests
url = "https://app.vumasign.com/api/v1/templates"
payload = {
"file": "JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=",
"roles": ["Employee"]
}
headers = {
"Idempotency-Key": "<idempotency-key>",
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
'Idempotency-Key': '<idempotency-key>',
Authorization: 'Bearer <token>',
'Content-Type': 'application/json'
},
body: JSON.stringify({
file: 'JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=',
roles: ['Employee']
})
};
fetch('https://app.vumasign.com/api/v1/templates', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.vumasign.com/api/v1/templates"
payload := strings.NewReader("{\n \"file\": \"JVBERi0xLjcKJYGBgYEKCjUgMCBvYmoKPDwKL0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCAyMDcKPj4Kc3RyZWFtCnicjY9BS0MxEITv+ytyFsRNNjubQCm8vubhwYuQP1BKLRV7qIi/331VD5UWJATCzsx+mROtOnGYz/ueHh53b5+7j8N2c29cSy5spYaI0F8o5dCfKJ6tMYCD+e1HWmTF2hgjJqtYQ9EsJ4Zgml+WECGWl6G/Ur+j1umZTreo1aMoSVFCjFepcOlMhakzRyc1JzhXhv8xEjMX1lpwk6HfzWSU0TLUimUZLnrqzLv2BxOfJQzu8Nap4E8qVRmw8p3VtZ+8Tr95V5u0ix5fVGNcUwplbmRzdHJlYW0KZW5kb2JqCgo2IDAgb2JqCjw8Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCi9UeXBlIC9PYmpTdG0KL04gNAovRmlyc3QgMjAKL0xlbmd0aCAyNjMKPj4Kc3RyZWFtCnicZVDRSgMxEHzPV+wP2E1i7i4HpdCWVkFEaYUK4kN6F46UkkgvJ/XvzcbTUiQv2Z3Zmd0RwEGCUnALlQYFouQwnTJ8+fqwgM+msz3DB9f28JZQDht4Z7gMg48g2GzGLtylieYYOvYzBILI14x18JHhdtjHXFJTMFyY3hICeG+Pnza6xjBc+Sa0zneAO+fnvne/jWtFsiLDk6V9siNubB+GU5NWIF5Wps+f+E3Fa600r3Sdjs4jF6yulCy1LEr9H5Occ82LWpcjllbB16f9wTbZgsrVOd5to4l2bFDv0bbOLMI5JcjTK+piIjVoJSZpg5Tm3PsQKd+crI/pFqqKMe0k8Q0eU3JYCmVuZHN0cmVhbQplbmRvYmoKCjcgMCBvYmoKPDwKL1NpemUgOAovUm9vdCAyIDAgUgovRmlsdGVyIC9GbGF0ZURlY29kZQovVHlwZSAvWFJlZgovTGVuZ3RoIDM5Ci9XIFsgMSAyIDIgXQovSW5kZXggWyAwIDggXQo+PgpzdHJlYW0KeJwVxLERADAIA7G3c0edMbIw8xKsQsCMKUhKTkdckN7mhg9QkQL4CmVuZHN0cmVhbQplbmRvYmoKCnN0YXJ0eHJlZgo2NjEKJSVFT0Y=\",\n \"roles\": [\n \"Employee\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Idempotency-Key", "<idempotency-key>")
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}{
"id": "01960000-0000-4000-8000-0000000007e1",
"name": "Employment contract",
"created_at": "2026-09-01T08:30:00.000Z",
"documents": [
{
"id": "01960000-0000-4000-8000-00000000d0c5",
"page_count": 1
}
],
"pages": [
{
"document_id": "01960000-0000-4000-8000-00000000d0c5",
"page": 1,
"width": 595.28,
"height": 841.89,
"rotation": 0
}
],
"roles": [
{
"name": "Employee",
"routing_type": "sign"
}
],
"subjects": [
{
"key": "employee",
"label": "Employee"
}
],
"questions": [],
"fields": [
{
"id": "01960000-0000-4000-8000-00000000f1e1",
"type": "text",
"label": "Full name",
"required": true,
"role": "Employee",
"document_id": "01960000-0000-4000-8000-00000000d0c5",
"page": 1,
"subject": "employee",
"data_key": "full_name",
"question_id": null,
"options": null,
"rect": {
"x": 0.1007929,
"y": 0.2107045,
"w": 0.5288973,
"h": 0.0213805
}
}
]
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}Authorizations
Authorization: Bearer vsk_live_…. Chosen over a bespoke X-API-KEY header because every client, proxy and log-redaction rule already knows this one. What a key may DO is its scopes — see x-scopes at the root of this document and x-required-scope on each operation. The scope list is not written here because OpenAPI reserves a requirement’s scope array for oauth2 and openIdConnect and requires it to be empty for an http scheme.
Headers
Any string that identifies this request; a UUID is the usual choice, and at most 255 characters. ⚠️ REQUIRED, because an upload is the request most likely to lose its answer — the body is the largest this API accepts and the reply comes after the document has been parsed twice. Retrying with the same key returns the same template rather than authoring a second one from the same document.
⚠️ AND IT IS ANSWERED CONSERVATIVELY IN ONE CASE. If a previous request under this key stopped partway and this API cannot establish whether it created a template, you are told so with request_in_progress and asked to look — it will not guess, because a wrong guess is a second template. Call GET /api/v1/templates; if it is not there, retry with a NEW key.
The caller’s own request identifier.
"01960000-0000-4000-8000-00000000ffff"
Body
The document, base64-encoded, and who signs it.
The document and its roles.
⚠️ THE WHOLE DOCUMENT, BASE64-ENCODED, AND THE EXAMPLE ABOVE IS A TRUNCATED STUB — replace it with your own document’s bytes or the request is refused as unreadable. A PDF or a Word .docx; the BYTES decide which, so there is no content_type field to get wrong, and a .docx is converted to PDF on our side. Standard alphabet only: no data: prefix, no whitespace, no line wrapping, and not the URL-safe -/_ variant. Base64 is about a third larger than the file it carries, and the whole request body is bounded at 8388608 bytes — so roughly 6291456 bytes of document, whatever its format or page count.
"JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c+PgplbmRvYmoK"
Who signs it, in order, at least one and at most six. ⚠️ THE ORDER IS LOAD-BEARING: it is a role’s position in the recipient list and its colour in the editor, and — when signing_mode is sequential — the order people are actually asked. Every field detected in the document is allocated to the first role that is not a cc, because a page cannot say which of three signers writes in a box; reassign them in the editor afterwards. Each comes back with an id, but nothing accepts it as input today — see Field.id.
One role’s name.
Optional. Used only to NAME the template when name is absent, exactly as the upload box suggests a name from the file you dropped on it. It never reaches storage — the object key is the content hash.
"employment-contract.pdf"
Optional. What to call the template. Absent or blank falls back to filename, and then to “Untitled document”. ⚠️ A name that is SENT and cannot be stored is refused, while a name that is not sent is not — a template’s name is editable afterwards, and refusing an upload over one would be a wall in front of the thing this endpoint exists to make free.
"Employment contract"
Optional. One entry per role, in the same order. ⚠️ IF YOU SEND IT AT ALL IT MUST HAVE EXACTLY ONE ENTRY PER ROLE — a short list is refused rather than padded, because from an API caller a short list is far more likely to be an off-by-one than an intention. Omit it entirely and every role signs. A cc is never asked to fill anything in, which is why detected fields skip past one. ⚠️ IT IS SENT THE FINISHED COPY ONLY WHEN WE SEND THE COMPLETION NOTICES — completion_delivery: vumasign, the default for an envelope sent from the dashboard. Under integrator, the default for an envelope sent with an API key, nobody is sent anything at completion, a cc included: telling them is yours.
What this role is asked to do.
sign, approve, cc Optional, default parallel — everybody is asked at once. sequential asks them in the order roles is written, each invitation going out only when the one before it is finished.
parallel, sequential "parallel"
Optional. The people this document asks about, so that a subject(...) tag can address a field to one of them. Omitted means none, and every subject(...) tag is then refused. At most 200. Two subjects with the same key, or with labels that differ only in case, are refused.
200Show child attributes
Show child attributes
Response
The template, in exactly the shape GET /api/v1/templates/{templateId} returns — same fields, same ids, same everything, because it is read back through the same function rather than assembled separately. Idempotency-Replayed says whether this request authored it (false) or is being shown an earlier one’s result (true); the status is 201 either way, deliberately, so a client branching on it behaves identically on a retry.
The template, with every field’s id.
The template’s id. A bare uuid, no prefix.
What the sender called it.
ISO 8601, UTC. Also the order this list is in — newest first.
The pack, in the order it is stacked.
Show child attributes
Show child attributes
⚠️ THE DENOMINATOR FOR EVERY Field.rect, and a client doing field matching cannot skip it: a rectangle of fractions is dimensionless without the page it is a fraction of. Join on (document_id, page), the same pair fields is joined by.
Every page of every document of the pack, in the pack’s order and then by page number — the order a signer scrolls.
Show child attributes
Show child attributes
The signing roles, in routing order.
Show child attributes
Show child attributes
The people whose data this template collects.
Show child attributes
Show child attributes
The tickbox questions and how many answers each takes.
Show child attributes
Show child attributes
⚠️ A FLAT LIST, NOT A MAP KEYED BY ADDRESS. Several fields sharing one (subject, data_key) pair is NORMAL — real forms ask for an ID number on the application and again on the declaration, and initials in the footer of all nine pages. A value supplied for an address means it for every box that asks.
Show child attributes
Show child attributes