curl --request POST \
--url https://app.vumasign.com/api/v1/envelopes \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"template_id": "01960000-0000-4000-8000-0000000007e1",
"recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
]
}
'import requests
url = "https://app.vumasign.com/api/v1/envelopes"
payload = {
"template_id": "01960000-0000-4000-8000-0000000007e1",
"recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
]
}
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({
template_id: '01960000-0000-4000-8000-0000000007e1',
recipients: [{role: 'Employee', name: 'Thandi Mokoena', email: 'thandi@example.test'}]
})
};
fetch('https://app.vumasign.com/api/v1/envelopes', 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/envelopes"
payload := strings.NewReader("{\n \"template_id\": \"01960000-0000-4000-8000-0000000007e1\",\n \"recipients\": [\n {\n \"role\": \"Employee\",\n \"name\": \"Thandi Mokoena\",\n \"email\": \"thandi@example.test\"\n }\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-0000000000e5",
"status": "draft",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": null,
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005e",
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test",
"routing_type": "sign",
"status": "pending",
"invitation_delivered": null,
"embedded": false,
"external_ref": null
}
]
}{
"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>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}Create an envelope from a template, and optionally send it.
THE ENDPOINT THAT EMAILS REAL PEOPLE AND SPENDS REAL MONEY, and the reason Idempotency-Key is required rather than optional.
⚠️ A REPLAY RETURNS THE SAME ENVELOPE AND CREATES NOTHING. Send the identical body under the identical key and you receive the first attempt’s answer, byte for byte, with Idempotency-Replayed: true. No second envelope, no second invitation, no second billed document. That is what makes a timeout safe to retry — and none of the three products this API was designed against offers it.
A DIFFERENT BODY UNDER A USED KEY IS idempotency_key_reused, not a replay. A key identifies one request; answering a second one with the first one’s envelope is silent data loss wearing a 201.
KEYS ARE REMEMBERED FOR 1 DAY, per organisation and per environment (live and test never answer for one another). Past that the key is forgotten and the same string is a new request.
⚠️ values IS A LIST OF TRIPLES AND ONE OF THEM MAY FILL SEVERAL BOXES. Read GET /api/v1/templates/{templateId} first: it is where the (subject, data_key) pairs come from.
A REFUSAL CREATES NOTHING. Every 4xx below leaves no envelope behind — including a send refused after the draft was built, which is deleted again — and leaves the idempotency key free for a corrected retry.
curl --request POST \
--url https://app.vumasign.com/api/v1/envelopes \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"template_id": "01960000-0000-4000-8000-0000000007e1",
"recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
]
}
'import requests
url = "https://app.vumasign.com/api/v1/envelopes"
payload = {
"template_id": "01960000-0000-4000-8000-0000000007e1",
"recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
]
}
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({
template_id: '01960000-0000-4000-8000-0000000007e1',
recipients: [{role: 'Employee', name: 'Thandi Mokoena', email: 'thandi@example.test'}]
})
};
fetch('https://app.vumasign.com/api/v1/envelopes', 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/envelopes"
payload := strings.NewReader("{\n \"template_id\": \"01960000-0000-4000-8000-0000000007e1\",\n \"recipients\": [\n {\n \"role\": \"Employee\",\n \"name\": \"Thandi Mokoena\",\n \"email\": \"thandi@example.test\"\n }\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-0000000000e5",
"status": "draft",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": null,
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005e",
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test",
"routing_type": "sign",
"status": "pending",
"invitation_delivered": null,
"embedded": false,
"external_ref": null
}
]
}{
"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>"
}
}{
"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. Without it a timeout is unresolvable: you cannot learn whether the envelope was created, and retrying makes a second one and emails everybody twice. The caller’s own request identifier.
"01960000-0000-4000-8000-00000000ffff"
Body
The template, the people, the values, and whether to send.
The request.
The template to instantiate. Layout is authored there, never here (§3).
"01960000-0000-4000-8000-0000000007e1"
⚠️ THE ORDER OF THIS ARRAY IS THE ROUTING ORDER. It becomes order_index, which is what a template with sequential signing gates on — so reordering it is a different request, and an idempotency key will not replay across the two.
Show child attributes
Show child attributes
Optional. Values to prefill, as triples. Omitted means none. ⚠️ A signature, a date_signed, a signer_name and a signer_email may not be prefilled — they are acts, or they are written by this system.
Show child attributes
Show child attributes
Optional, default false. False creates a draft and emails nobody; true sends it, consuming the plan’s allowance and delivering an invitation to everyone whose turn it is.
Optional, default null — no deadline. When this envelope stops being signable, as an ISO 8601 instant. ⚠️ AN ABSOLUTE TIME RATHER THAN A DURATION, so a retried request carrying the same Idempotency-Key asks for the same deadline the first attempt did. At least 1 hour out and at most 365 days out; anything else is invalid_request. ⚠️ IT CANNOT BE CHANGED AFTERWARDS — there is no endpoint that extends a deadline yet, and an envelope that reaches one while partly signed can only be voided and started again, discarding the signatures already collected. It is enforced: every signing link is capped by it, no link can be reissued past it, and the envelope transitions to expired and emits envelope.expired once it passes.
Optional, default null — the organisation’s default brand. Which brand the envelope goes out under: the letterhead, the colours and the sending name a signer sees. ⚠️ THIS IS THE FIELD FOR SENDING ON BEHALF OF MANY CUSTOMER BRANDS FROM ONE ACCOUNT — change it between calls and the same integration sends Customer A’s paperwork under Customer A’s brand and Customer B’s under Customer B’s.
⚠️ AN ID THAT NAMES NO LIVE BRAND OF YOUR ORGANISATION IS REFUSED WITH brand_unknown, NOT IGNORED — including one that has been archived. The branding an envelope went out under is frozen the moment it is sent and cannot be corrected afterwards, so a wrong constant in your configuration would otherwise put somebody else’s letterhead on every envelope you ever send and nothing would say so.
It is recorded on the envelope, so a draft created with send: false still goes out under this brand when it is sent later.
Who tells the recipients the envelope is done and sends them the executed document. vumasign (the default for a send from the dashboard) means we email the sender and every recipient we are able to email, with the sealed PDF attached. integrator (the default for a send made with an API key) means we send nothing at completion and you do it. Omit it and the origin of the send decides — an envelope created with send: false and then sent from the dashboard is vumasign. It cannot be changed once the envelope has been sent.
⚠️ AN embedded RECIPIENT IS NEVER EMAILED, WHATEVER THIS SAYS. We issue one no signing link and send them no invitation, because their address may be an identifier of yours rather than a mailbox — so they are not sent the completed document either, even under vumasign. Delivering the executed document to an embedded recipient is yours on every envelope, and this field only decides who tells everybody else.
⚠️ IF YOU TAKE THIS ON, SUBSCRIBE TO envelope.sealed RATHER THAN envelope.completed. envelope.completed fires when the last person signs, which is about a minute before the executed document exists, and envelope.sealing_failed tells you it never will.
vumasign, integrator Response
The envelope, as it stands. Idempotency-Replayed says whether this request created it (false) or is being shown an earlier one’s result (true); the status is 201 either way, deliberately, so that a client branching on it behaves identically on a retry.
The envelope.
The envelope’s id. A bare uuid.
draft or sent from this endpoint. Later states arrive as people act.
The template this was made from, or null when there was none — an envelope created by POST /api/v1/envelopes/one-off carries the document itself and never had a template. Branch on null, never on the empty string.
What the signers see naming the document. Defaults to the template’s name.
ISO 8601, UTC.
ISO 8601, UTC. Null while it is a draft.
ISO 8601, UTC. Null when this envelope has no deadline, which is the default. Read back from the stored value rather than echoed, so an offset you sent comes back as the same instant in UTC.
In routing order.
Show child attributes
Show child attributes