Bulk send
A bulk send takes a template and a list of rows, one per document, and sends each row as a normal signature request. Every row is validated before anything moves, the monthly allowance is reserved for the whole batch at dispatch, rows go out at a steady rate, and each row reports the same delivery states your webhook endpoints receive.
What it is for
Transactional documents your recipients expect: renewals, onboarding packets, annual acknowledgements, anything each person already has a reason to sign. The acceptable use policy bars unsolicited or promotional content dressed as a signature request, and batches receive proportionally stricter automated review than single sends. A workspace that sends more than 2,000 documents in one day is flagged for review; the flag never stops a batch.
The flow
- Create the batch with
POST /v1/bulk-sends: the template and the rows. Every row is checked against the template's definition. If any row is invalid, the request answers 422 with a per-row list and nothing is stored as sendable. - Dispatch with
POST /v1/bulk-sends/{id}/dispatch: the allowance is reserved for every row as one block. If fewer documents remain than rows, the whole batch is refused with 402 and nothing is sent. - Rows send at 10 rows every 2 seconds per workspace. Each row becomes a document with its own
doc_id, created and sent in one step. - Track with
GET /v1/bulk-sends/{id}: per-row status anddelivery_state(sent, opened, signed, completed, bounced, rejected), updated from the same event stream webhooks use. - Cancel with
POST /v1/bulk-sends/{id}/cancel: rows still queued are canceled and their allowance refunded; documents already sent stay sent.
Create a batch
Rows carry real people for each of the template's roles, in signing
order, and optional prefills keyed by field name (or by index when a
field has no name). A row with a recipient count that does not match
the template, a malformed email, an unknown prefill key, a missing
prefill for a read-only field, or an authentication level other than
link or otp is invalid.
curl -X POST https://signenvoystaging.fly.dev/v1/bulk-sends \
-H "Authorization: Bearer $SENV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tpl_7Q2mK9...",
"source_filename": "renewals-2026.csv",
"rows": [
{"recipients": [{"name": "Ada Lovelace", "email": "ada@example.com"}],
"form_values": {"po_number": "PO-1041"}},
{"recipients": [{"name": "Grace Hopper", "email": "grace@example.com"}],
"form_values": {"po_number": "PO-1042"}}
]
}' HTTP/1.1 201 Created
{
"id": "5b1f1c3e-9f3a-4a6e-9a7e-2c1d4e5f6a7b",
"template_id": "tpl_7Q2mK9...",
"mode": "live",
"source_filename": "renewals-2026.csv",
"row_count": 2,
"status": "ready",
"sent_count": 0, "failed_count": 0, "canceled_count": 0,
"review_flagged": false,
"created_at": "2026-08-23T12:00:00+00:00",
"dispatched_at": null, "finished_at": null,
"rows": [
{"row_no": 1, "status": "queued", "document_id": null, "delivery_state": null,
"error": null, "sent_at": null, "recipients": [{"name": "Ada Lovelace", "email": "ada@example.com"}]},
{"row_no": 2, "status": "queued", "document_id": null, "delivery_state": null,
"error": null, "sent_at": null, "recipients": [{"name": "Grace Hopper", "email": "grace@example.com"}]}
]
}
Batch ids are plain UUIDs; only documents and templates carry the
doc_ and tpl_ prefixes. A batch takes at most
5,000 rows; more answers
bulk-too-many-rows.
When a row is invalid
The whole request is refused. The batch is stored with status
invalid so you can read it back, but it cannot be
dispatched; fix the rows and create a new batch.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://docs.signenvoy.com/errors/bulk-rows-invalid",
"title": "Some bulk rows are invalid",
"status": 422,
"detail": "2 of 120 rows are invalid; nothing was sent.",
"docs_url": "https://docs.signenvoy.com/errors/bulk-rows-invalid",
"bulk_send_id": "5b1f1c3e-9f3a-4a6e-9a7e-2c1d4e5f6a7b",
"rows": [
{"row_no": 4, "errors": ["recipients[0].email: not a valid email address"]},
{"row_no": 5, "errors": ["form_values: unknown field key(s) 'po_numbre'"]}
]
}
See bulk-rows-invalid
for the full list of reasons.
The CSV column rule
The dashboard's bulk flow and senv bulk create (from
senv 0.2.0) take a CSV and turn it into the rows above. Both
read the same columns, so a file prepared for one works in the other.
recipient1_name,recipient1_email,recipient2_name,recipient2_email, and so on: one pair per role of the template, numbered by signing position starting at 1.- One column per field name for prefills, for example
po_number. A field without a name is addressed by its index, for example3. - UTF-8 encoding; the header row is required; every row needs every recipient column filled.
recipient1_name,recipient1_email,recipient2_name,recipient2_email,po_number
Ada Lovelace,ada@example.com,Miguel Ortega,m.ortega@example.com,PO-1041
Grace Hopper,grace@example.com,Miguel Ortega,m.ortega@example.com,PO-1042
That file becomes two rows, each with two recipients and
form_values: {"po_number": "..."}. Unknown columns are
reported per row rather than ignored, because a silently dropped column
is how a prefill goes missing on 400 documents at once.
Dispatch and the allowance
Dispatch reserves one document of your monthly allowance per row, as a
block, before the first row sends. If fewer documents remain than the
batch has rows, the batch is refused whole with
envelope-allowance-exhausted
(402) and nothing is sent. This holds even when auto-advance is on:
batches never move a workspace to the next band on their own. Upgrade
first, then dispatch.
Rows that never produce a sent document are refunded: a row that fails at the engine, and every queued row of a canceled batch. A batch that is already dispatching when the cap is reached completes; in-flight sends are never cut off.
Dispatching a batch that is not ready answers
bulk-not-ready (409)
with the batch's current status.
Watch progress
curl https://signenvoystaging.fly.dev/v1/bulk-sends/5b1f1c3e-9f3a-4a6e-9a7e-2c1d4e5f6a7b?status=failed \
-H "Authorization: Bearer $SENV_API_KEY" ?status= filters rows (queued, sending,
sent, failed, canceled). A row's
document_id is a normal document: it has its own page,
timeline, downloads, and events. Its delivery_state follows
the document's events without polling each document yourself. The batch's
own status moves ready to dispatching to
done (or failed when every row failed, or
canceled).
Batches in the documents list
GET /v1/documents leaves batch members out by default, so a
400-row batch does not bury everything else. Pass
?batch=<id> to list exactly that batch's documents. The
dashboard shows one row per batch the same way.
From the command line
From senv 0.2.0:
senv bulk create tpl_7Q2mK9... renewals-2026.csv --dispatch
senv bulk status 5b1f1c3e-9f3a-4a6e-9a7e-2c1d4e5f6a7b --failed
senv bulk cancel 5b1f1c3e-9f3a-4a6e-9a7e-2c1d4e5f6a7b create parses the CSV locally with the column rule above and
renders invalid rows as a table; --dispatch asks for
confirmation before sending unless --yes is given. Until
then, the API calls above are the path.
Test mode
A batch created with a test key creates test documents: no mail is sent (the captured emails appear under Settings, Test mode), sealed PDFs carry the watermark, and nothing counts toward the allowance or the daily review threshold.