curl --request POST \
--url https://app.vumasign.com/api/v1/envelopes/batches \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"template_id": "01960000-0000-4000-8000-0000000007e1",
"rows": [
{
"recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
]
},
{
"recipients": [
{
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@example.test"
}
]
}
]
}
'import requests
url = "https://app.vumasign.com/api/v1/envelopes/batches"
payload = {
"template_id": "01960000-0000-4000-8000-0000000007e1",
"rows": [{ "recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
] }, { "recipients": [
{
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@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',
rows: [
{
recipients: [{role: 'Employee', name: 'Thandi Mokoena', email: 'thandi@example.test'}]
},
{
recipients: [{role: 'Employee', name: 'A. Dlamini', email: 'a.dlamini@example.test'}]
}
]
})
};
fetch('https://app.vumasign.com/api/v1/envelopes/batches', 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/batches"
payload := strings.NewReader("{\n \"template_id\": \"01960000-0000-4000-8000-0000000007e1\",\n \"rows\": [\n {\n \"recipients\": [\n {\n \"role\": \"Employee\",\n \"name\": \"Thandi Mokoena\",\n \"email\": \"thandi@example.test\"\n }\n ]\n },\n {\n \"recipients\": [\n {\n \"role\": \"Employee\",\n \"name\": \"A. Dlamini\",\n \"email\": \"a.dlamini@example.test\"\n }\n ]\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))
}{
"batch_id": "01960000-0000-4000-8000-0000000000ba",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"count": 2,
"sent": 2,
"failed": 0,
"results": [
{
"index": 0,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e5",
"status": "sent",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": "2026-09-02T09:05:00.000Z",
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005e",
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test",
"routing_type": "sign",
"status": "sent",
"invitation_delivered": true,
"embedded": false,
"external_ref": null
}
]
},
"error": null
},
{
"index": 1,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e6",
"status": "sent",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": "2026-09-02T09:05:00.000Z",
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005f",
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@example.test",
"routing_type": "sign",
"status": "sent",
"invitation_delivered": true,
"embedded": false,
"external_ref": null
}
]
},
"error": null
}
]
}{
"batch_id": "01960000-0000-4000-8000-0000000000ba",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"count": 2,
"sent": 1,
"failed": 1,
"results": [
{
"index": 0,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e5",
"status": "sent",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": "2026-09-02T09:05:00.000Z",
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005e",
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test",
"routing_type": "sign",
"status": "sent",
"invitation_delivered": true,
"embedded": false,
"external_ref": null
}
]
},
"error": null
},
{
"index": 1,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e6",
"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-00000000005f",
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@example.test",
"routing_type": "sign",
"status": "pending",
"invitation_delivered": null,
"embedded": false,
"external_ref": null
}
]
},
"error": {
"code": "send_allowance_exhausted",
"message": "You have sent 5 of the 5 documents the Free plan includes each month, so this one was not sent. Nothing already sent is affected, and every signed document stays available. A paid plan lifts the limit and never blocks a send."
}
}
]
}{
"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 and send many envelopes from one template, in one request.
ONE REQUEST, ONE Idempotency-Key, UP TO 100 ENVELOPES. What this saves is not our time but your error handling: a thousand loops of POST /api/v1/envelopes is a thousand keys to mint and persist, a thousand timeouts to resolve, and a thousand places to be halfway through.
⚠️ THE STATUS LINE IS THE ANSWER. YOU NEVER HAVE TO WALK THE BODY TO FIND OUT WHETHER IT WORKED.
201— every row went out (or, withsend: false, every draft exists).failedis 0.207— at least one row did not.failedsays how many andresults[i].errorsays why. This is the only status that requires reading a body.- any
4xx— nothing was created at all.
⚠️ ONE BAD ROW REFUSES THE WHOLE BATCH, AND THAT IS DELIBERATE. Every refusal your own data can earn — an unknown role, an address matching no field, a value that cannot be drawn, a mailbox a test key may not write to — is decided in ONE transaction before anything is sent, so a hundred envelopes roll back together and your key is free for a corrected retry. A mistake in mapping code is systematic: if row 7 names a role the template does not have, rows 8 to 99 probably do too, and sending 93 binding documents to prove it is the expensive way to find out. Refusals name the row: rows[7].recipients[2].role.
PAST THAT POINT IT IS PER ROW, because an email handed to a provider cannot be recalled. A row whose send is refused KEEPS ITS DRAFT, with its recipients and values already on it, and its id is in the answer — so the remedy is POST /api/v1/envelopes/{envelopeId}/send on the ones that failed rather than rebuilding the ones that did not.
⚠️ A REPLAY RETURNS THE SAME BATCH AND CREATES NOTHING, including a replay of a 207 — which returns the same 207, failed rows and all, rather than retrying them. If a replay could DO something, two replays could give two different answers and a retry after a timeout would tell you nothing.
⚠️ IT IS SYNCHRONOUS. There is no job to poll, because the answer is the response. That is what the 100-row bound buys, and it is why the bound is not larger.
⚠️ IT COSTS N AGAINST THE RATE LIMIT, NOT 1 — one unit per row, because one row is one envelope and an envelope is what costs us. A batch that does not fit in what the minute has left is refused WHOLE with rate_limited, never partly, and is charged 1 rather than N for being refused. RateLimit-Remaining tells you how many rows would fit now.
curl --request POST \
--url https://app.vumasign.com/api/v1/envelopes/batches \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: <idempotency-key>' \
--data '
{
"template_id": "01960000-0000-4000-8000-0000000007e1",
"rows": [
{
"recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
]
},
{
"recipients": [
{
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@example.test"
}
]
}
]
}
'import requests
url = "https://app.vumasign.com/api/v1/envelopes/batches"
payload = {
"template_id": "01960000-0000-4000-8000-0000000007e1",
"rows": [{ "recipients": [
{
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test"
}
] }, { "recipients": [
{
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@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',
rows: [
{
recipients: [{role: 'Employee', name: 'Thandi Mokoena', email: 'thandi@example.test'}]
},
{
recipients: [{role: 'Employee', name: 'A. Dlamini', email: 'a.dlamini@example.test'}]
}
]
})
};
fetch('https://app.vumasign.com/api/v1/envelopes/batches', 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/batches"
payload := strings.NewReader("{\n \"template_id\": \"01960000-0000-4000-8000-0000000007e1\",\n \"rows\": [\n {\n \"recipients\": [\n {\n \"role\": \"Employee\",\n \"name\": \"Thandi Mokoena\",\n \"email\": \"thandi@example.test\"\n }\n ]\n },\n {\n \"recipients\": [\n {\n \"role\": \"Employee\",\n \"name\": \"A. Dlamini\",\n \"email\": \"a.dlamini@example.test\"\n }\n ]\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))
}{
"batch_id": "01960000-0000-4000-8000-0000000000ba",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"count": 2,
"sent": 2,
"failed": 0,
"results": [
{
"index": 0,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e5",
"status": "sent",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": "2026-09-02T09:05:00.000Z",
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005e",
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test",
"routing_type": "sign",
"status": "sent",
"invitation_delivered": true,
"embedded": false,
"external_ref": null
}
]
},
"error": null
},
{
"index": 1,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e6",
"status": "sent",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": "2026-09-02T09:05:00.000Z",
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005f",
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@example.test",
"routing_type": "sign",
"status": "sent",
"invitation_delivered": true,
"embedded": false,
"external_ref": null
}
]
},
"error": null
}
]
}{
"batch_id": "01960000-0000-4000-8000-0000000000ba",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"count": 2,
"sent": 1,
"failed": 1,
"results": [
{
"index": 0,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e5",
"status": "sent",
"template_id": "01960000-0000-4000-8000-0000000007e1",
"title": "Employment contract",
"created_at": "2026-09-02T09:00:00.000Z",
"sent_at": "2026-09-02T09:05:00.000Z",
"expires_at": null,
"recipients": [
{
"id": "01960000-0000-4000-8000-00000000005e",
"role": "Employee",
"name": "Thandi Mokoena",
"email": "thandi@example.test",
"routing_type": "sign",
"status": "sent",
"invitation_delivered": true,
"embedded": false,
"external_ref": null
}
]
},
"error": null
},
{
"index": 1,
"envelope": {
"id": "01960000-0000-4000-8000-0000000000e6",
"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-00000000005f",
"role": "Employee",
"name": "A. Dlamini",
"email": "a.dlamini@example.test",
"routing_type": "sign",
"status": "pending",
"invitation_delivered": null,
"embedded": false,
"external_ref": null
}
]
},
"error": {
"code": "send_allowance_exhausted",
"message": "You have sent 5 of the 5 documents the Free plan includes each month, so this one was not sent. Nothing already sent is affected, and every signed document stays available. A paid plan lifts the limit and never blocks a send."
}
}
]
}{
"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; at most 255 characters. ⚠️ REQUIRED, and this is the endpoint it matters most on: without it a timeout is unresolvable and a retry creates a second hundred envelopes and emails everybody in them twice. The caller’s own request identifier.
"01960000-0000-4000-8000-00000000ffff"
Body
The template, the rows, and whether to send.
The request.
The template every row instantiates. ⚠️ BATCH-LEVEL, AND ONE TEMPLATE IS THE FEATURE — a request that could name a different template per row would be POST /api/v1/envelopes in a loop with the loop moved inside our process.
"01960000-0000-4000-8000-0000000007e1"
The recipient sets, one envelope each, in order. ⚠️ AT MOST 100 ROWS AND AT MOST 1000 RECIPIENTS IN TOTAL ACROSS THEM; either bound is invalid_request and nothing is created. results[i] in the response is what happened to rows[i], and every result also carries its own index.
Show child attributes
Show child attributes
Optional, default false, and it applies to the whole batch. False creates the drafts and emails nobody; true sends every row, consuming one unit of the plan’s allowance per envelope.
Optional, default null. One deadline for every envelope in the batch. Same rules as on POST /api/v1/envelopes: an absolute ISO 8601 instant, at least 1 hour and at most 365 days out.
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.
⚠️ PER BATCH, NOT PER ROW. One request sends under one brand — which is the reseller case exactly, since a batch already names one template and two customers do not share one. Two brands means two batches. A row-level override remains additive if that ever changes.
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.
⚠️ PER BATCH, NOT PER ROW. One request is one answer, written onto every envelope it creates. Two answers means two batches. A row-level override remains additive if that ever changes.
vumasign, integrator Response
Every row succeeded. failed is 0 and every results[i].error is null. Idempotency-Replayed says whether this request created the batch (false) or is being shown an earlier one’s result (true).
The batch.
This request’s own identifier, recorded on every envelope it created. There is no endpoint that takes it back — the answer is in your hand, and a caller who lost it replays their Idempotency-Key.
The template every row was made from.
How many envelopes exist. Always the number of rows in the request: every row produces an envelope, or the whole request is refused.
How many went out. Zero when send was false.
⚠️ THE ONE FIELD THAT ANSWERS "DID IT WORK", AND IT IS AN INTEGER RATHER THAN A WALK. Zero on a 201 and non-zero on a 207, always — so the status line and this number never disagree, and a client that reads neither the body nor this field still gets the truth from the status.
One entry per row, in the order the request listed them.
Show child attributes
Show child attributes