Submit an asynchronous batch that creates or updates cards in a pipe or records in a database from an uploaded CSV or JSON file.
Before You Begin
🔗 Use the GraphQL Playground to execute the mutation in this guide.
➡️ New to GraphQL? Learn how to navigate the Playground with our Playground Basics Guide.
Why this use case
batchUpsert submits an asynchronous batch that creates or updates cards in a pipe or records in a database. Use it when you need to load or sync many items in one call instead of issuing one mutation per card.
The source of a batch is always an uploaded CSV or JSON file. You upload the file first, then submit a mutation that points at it and maps its columns to the fields you want to write. The mutation returns a batch operation envelope immediately and processing continues in the background.
Asynchronous by design: read this first
batchUpsert is not a synchronous write. When the mutation returns, nothing has been written yet: the batch has only been accepted.
There is no guaranteed processing time and no completion SLA. Elapsed time depends on the number of rows, the number of fields written per row, whether automations and webhooks fire for each write, and how much other work the queue is already carrying. Two identical submissions can take very different amounts of time.
Because of that:
- Use
callbackUrl. It is the recommended way to learn that a batch finished. Pipefy calls your endpoint once the batch reaches a terminal status, with the final counts and the error report link in the payload. See Step 4. - Do not build a fixed wait. Sleeping for a fixed interval and then assuming the batch is done will produce wrong results under load.
- If you must poll, poll with backoff. The
batchOperationsquery is available as a fallback, but a tight polling loop only adds load and does not make the batch finish sooner. - A batch can also stall. If the internal processing events for a batch are lost, the batch stays in
processingand never calls back. Treat a batch that has not reached a terminal status after an unreasonably long time as an operational issue to report, not as a batch to resubmit blindly.
Availability
batchUpsert is a Service Account only operation and is served behind the batch operations feature flag. The field is present in the schema only for organizations where the feature is enabled; if your organization does not have it enabled, the field will not appear and the mutation will fail validation.
Prerequisites
- Authentication: Use a Service Account token (Personal Access Tokens are deprecated). The mutation rejects any non-service-account caller.
- Permissions: Your Service Account must be able to create/update cards in the target pipe or database.
- IDs: See our Get resource IDs page for how to find the
repoId(pipe or database ID) and the field slugs you will write or match on. - A source file: a CSV or JSON file, uploaded to your organization's storage as described in Step 1.
Step 1: Upload the source file
- Call
createPresignedUrlwith your organization ID and the file name. PUTthe file bytes to the returnedurl.- Keep the
downloadUrlfrom that response. It is what you pass asfile.sourceFile.
The uploaded object must live under your own organization's storage path (orgs/<your-org-uuid>/...), which is the shape createPresignedUrl produces. A file that resolves to another organization's path, or to a path outside orgs/, is rejected with PERMISSION_DENIED at submission time.
Step 2: Submit the batch
mutation BatchUpsert($input: BatchUpsertInput!) {
batchUpsert(input: $input) {
batchOperation {
id
operationType
status
totalCount
resultFile
createdAt
finishedAt
suppressAutomations
suppressWebhooks
requestedBy {
id
name
}
}
}
}
Variables
{
"input": {
"repoId": "301234567",
"matchBy": ["email"],
"createIfNotFound": true,
"idempotencyKey": "sync-customers-2026-08-11",
"callbackUrl": "https://example.com/hooks/batch-upsert",
"suppressAutomations": false,
"suppressWebhooks": false,
"file": {
"sourceFile": "https://storage.pipefy.com/v1/signed/orgs/your-org-uuid/uploads/file-uuid/customers.csv",
"format": "csv",
"startRow": 2,
"fieldColumns": [
{ "fieldId": "email", "column": "Email" },
{ "fieldId": "company_name", "column": "Company" }
],
"titleColumn": "Name",
"dueDateColumn": "Deadline",
"labelsColumn": "Labels",
"assigneesColumn": "Owners"
}
}
}
Input fields
| Field | Required | Description |
|---|---|---|
repoId | Yes | The pipe or database whose cards/records are upserted |
file | Yes | The uploaded CSV/JSON file source and its column mapping (see the next table). This is the only intake source; there is no way to send items inline in the request body |
matchBy | No | Field identifiers used to locate an existing record (composite key, 1 to 3 entries). Omit for a pure create. Each entry is a field slug or one of the reserved keys $record_id and $title |
createIfNotFound | No | Create a record when no match is found (default true). When false, matchBy is required |
idempotencyKey | No | Returns the existing batch on a duplicate submission for the same repo (max 100 characters) |
callbackUrl | No | HTTP(S) URL notified when the batch finishes (max 500 characters). Strongly recommended |
suppressAutomations | No | Suppress automations for this batch (default false) |
suppressWebhooks | No | Suppress webhooks for this batch (default false) |
File source fields
| Field | Required | Description |
|---|---|---|
sourceFile | Yes | URL of the uploaded CSV or JSON file (from createPresignedUrl) |
format | Yes | csv or json. The format is always explicit: there is no detection from the file extension or its content |
fieldColumns | Yes | Field-to-column mapping. Each entry pairs a field slug (fieldId) with a column name (column) |
startRow | No | First data row, CSV only (default 2). The header line is read from the row immediately above it. Ignored for JSON |
recordIdColumn | No | Column carrying the record id, used with matchBy: ["$record_id"] |
titleColumn | No | Column carrying the card title (cards only) |
assigneesColumn | No | Column carrying card assignee ids or emails, comma-separated (cards only) |
dueDateColumn | No | Column carrying the card due date (cards only) |
labelsColumn | No | Column carrying card label ids, comma-separated (cards only) |
statusColumn | No | Accepted by the schema but not applied. See What is not supported |
Despite its name, fieldId carries a field slug (the stable string identifier the API uses for a field), not a numeric id.
Step 3: Read the acceptance response
{
"data": {
"batchUpsert": {
"batchOperation": {
"id": "8f3b1c9e-2a4d-4c7b-9f10-1d2e3a4b5c6d",
"operationType": "upsert_records",
"status": "queued",
"totalCount": 0,
"resultFile": null,
"createdAt": "2026-08-11T12:00:00Z",
"finishedAt": null,
"suppressAutomations": false,
"suppressWebhooks": false,
"requestedBy": {
"id": "1234567",
"name": "Integration Bot"
}
}
}
}
}
Returned fields
| Field | Description |
|---|---|
batchOperation.id | Public identifier of the batch operation (a UUID). Keep it |
batchOperation.operationType | The kind of batch operation (upsert_records) |
batchOperation.status | Current lifecycle status. A newly accepted batch is queued |
batchOperation.totalCount | Number of items in the batch. It is 0 on acceptance and is set once the file has been parsed |
batchOperation.resultFile | Temporary download URL for the error report; null until the batch produces errors |
batchOperation.createdAt | When the batch was submitted |
batchOperation.finishedAt | When the batch finished; null while it is unfinished |
batchOperation.suppressAutomations | Whether automations were suppressed for this batch |
batchOperation.suppressWebhooks | Whether webhooks were suppressed for this batch |
batchOperation.requestedBy | The user who submitted the batch; null if that user no longer exists |
status: "queued" and totalCount: 0 are the normal acceptance response. The row count is only known after the file is parsed, which happens after this response is returned. See List Batch Operations for the full status table.
Step 4: Wait for the result
Recommended: callbackUrl
callbackUrlWhen the batch reaches a terminal status (completed, completed_with_errors, or failed), Pipefy sends a POST to your callbackUrl with Content-Type: application/json and this body:
{
"data": {
"id": "8f3b1c9e-2a4d-4c7b-9f10-1d2e3a4b5c6d",
"status": "completed_with_errors",
"operation_type": "upsert_records",
"finished_at": "2026-08-11T12:04:37Z",
"total_count": 1500,
"item_counts": {
"pending": 0,
"processing": 0,
"succeeded": 1487,
"failed": 12,
"skipped": 1
},
"result_file": "https://storage.pipefy.com/v1/signed/.../errors.csv"
}
}
| Field | Description |
|---|---|
id | The batch operation UUID |
status | The terminal status |
operation_type | upsert_records |
finished_at | When the batch finished, ISO 8601 |
total_count | Number of items in the batch |
item_counts | Count per item status, always carrying every status key |
result_file | Temporary download URL for the error report, or null when the batch produced no failed row |
error | Present only when the whole batch failed, carrying the reason (for example a malformed file or an over-limit row count) |
Delivery notes:
- The callback fires once, on the transition into the terminal status. A duplicate internal event does not produce a second call.
- The URL is validated at submission time. A value that is not a valid
httporhttpsURL is rejected before the batch is created. - The request times out after 60 seconds by default and is retried up to 5 times on failure. Return a 2xx quickly and do your own work asynchronously.
- Your endpoint should be idempotent anyway, and should treat the payload as a signal to read the authoritative state rather than as the only copy of it.
- Reprocessing a batch with
reprocessBatchOperationproduces a new callback when the reprocessed run finishes.
Fallback: polling
If you cannot expose an endpoint, poll batchOperations for the batch and stop when status is completed, completed_with_errors, or failed. Use an increasing interval. Polling is a fallback, not the recommended integration.
Step 5: Handle failed rows
When the batch finishes with failed items, resultFile (and result_file in the callback) carries a temporary download URL for an error report describing every failed row.
The report format follows the source format:
- CSV source: a CSV whose first two columns are
rowanderror, followed by one column per mapped column of your file, carrying the row as it was read. - JSON source: a JSON array of
{ "row": <number>, "error": "<reason>", "data": { <the row as read> } }.
row is the position in your source file: for a CSV it counts from startRow, for a JSON array it is the 1-based position in the array. Only the columns your mapping referenced are echoed back, since unmapped columns are never read.
Error messages in the report are always in English, because the reader's locale is not known when the batch runs.
Once you have fixed the underlying data (or the state that caused the failure), use reprocessBatchOperation to re-run only the failed items. A subsequent run that fixes every row clears resultFile.
File format rules
Column headers and JSON keys are matched after trimming whitespace and lowercasing, so Email, email, and EMAIL all refer to the same column.
CSV
- The header line sits at
startRow - 1; data starts atstartRow. - Every header your mapping references must be present exactly once. A missing or duplicated header fails the whole batch.
- Encoding is UTF-8, with a byte order mark tolerated. The dialect is comma-separated, with no autodetection of other delimiters.
- Each non-blank data row becomes one item.
JSON
- The root must be an array of objects. Each mapped column names a top-level key of those objects.
- A key missing from an object is treated as a blank cell, not as an error. A mapping that references a key no object carries is silently read as all-blank, so double-check JSON column names.
- Values arrive as native types: scalars are stringified, and arrays are joined with a comma.
- Two keys in one object that normalize to the same name collapse, and the last one wins.
startRowdoes not apply.
Blank cells and blank rows
- A row whose referenced cells are all blank is skipped entirely and produces no item.
- A single blank cell in a mapped column clears its target rather than leaving it untouched: a field is written empty,
dueDateColumnclears the due date, andlabelsColumn/assigneesColumnclear labels and assignees. A blanktitleColumnresets the card to the default draft title. recordIdColumnis the exception: a blank record id cell is simply omitted, so the row is treated as a create rather than as an attempt to clear an id.- A column your mapping does not reference is never read and never written.
Value formats per field type
Cell values run through the same formatter the spreadsheet importer uses, dispatched on the target field's type.
| Field type | Expected cell value |
|---|---|
| Short text, long text, email, CPF, CNPJ | The raw text |
| Number | The numeric value |
| Currency | A number; it is normalized to two decimal places |
| Phone | A phone number Pipefy can parse; between 4 and 15 digits after normalization, otherwise the row fails |
| Date, datetime, due date | A parseable date/time string. An unparseable or out-of-range value fails the row |
| Select, radio | The option value. A numeric value is matched against the numeric equivalent of the configured options |
| Checklist (vertical or horizontal) | Comma-separated items |
Labels (label_select field, or labelsColumn) | Comma-separated label ids. Label names are not accepted, because a name can contain the comma the parser splits on. An id not present in the pipe fails the row |
Assignees (assignee_select field, or assigneesColumn) | Comma-separated user ids or emails. A digit-only token is read as a user id, anything else as an email. Names are not accepted, because a name does not uniquely identify a member. A value that matches no member of the repo fails the row |
A cell that the formatter rejects fails only that row, not the batch. The other rows continue.
Matching rules
matchBy decides whether a row updates an existing card/record or creates a new one.
- Omit
matchBy(withcreateIfNotFound: true, the default) for a pure create: every row becomes a new card or record. - Provide 1 to 3 keys to locate an existing card. The keys combine with
AND: a card must satisfy every key to be a match. More than three keys is rejected. - Matching is scoped to the target repo. A value belonging to a card in another pipe, database, or organization never matches.
Accepted keys:
| Key | Matches on |
|---|---|
| A single-value field slug | That field's value on the card. The value is compared in its stored form, so a date written in any parseable spelling still matches, and comparison is case-insensitive |
$record_id | The card/record id, taken from the column named by recordIdColumn |
$title | The card title, case-insensitive |
Outcomes per row:
| Situation | Result |
|---|---|
| Exactly one card matches | The row updates that card |
No card matches, createIfNotFound: true | The row creates a new card |
No card matches, createIfNotFound: false | The item is recorded as skipped |
| More than one card matches | The item fails with a message naming the ambiguous key. Pipefy never picks one |
Notes:
$record_idcan be used alone or combined with another key as a guard ("update this id, but only if its email also matches").- A non-unique key such as
$titlewill fail every row whose title is shared by more than one card. Prefer a key you know identifies at most one card. - A field that holds more than one value cannot be a key. Labels, assignees, checklists (vertical and horizontal), connections and attachments are rejected at submission, with an error naming the offending slug, and the batch is not created. Use a single-value field that identifies the card, or
$record_id. matchByis not validated against a field'suniqueflag. Any single-value field slug that exists in the repo is accepted, and ambiguity is reported per row rather than rejecting the whole submission.- A field configured as unique is enforced during the write. A row repeating a value another row of the same submission already claimed fails (only the first one is applied), and a row whose value is already held by another card fails. Both are deterministic, so reprocessing them changes nothing until the data is fixed.
Limits
| Limit | Value | How it is enforced |
|---|---|---|
| Rows per batch, side effects on | 5,000 | Default. Applies whenever suppressAutomations or suppressWebhooks is false |
| Rows per batch, side effects off | 10,000 | Applies only when both suppressAutomations and suppressWebhooks are true |
| Source file size | 50 MB | Checked at submission (rejected as INVALID_INPUT) and again before the file is read |
matchBy keys | 1 to 3 | Rejected at submission |
idempotencyKey length | 100 characters | Rejected at submission |
callbackUrl length | 500 characters | Rejected at submission |
Why the row limit depends on suppression: every created or updated card can trigger automations and webhooks, and those side effects are the dominant cost of a batch. Turning both off with suppressAutomations: true and suppressWebhooks: true removes that amplification, which is what allows the higher ceiling. Leave them on and the limit is 5,000.
The row limit is enforced when the file is parsed, not when the mutation is called. A file with more rows than the applicable limit is accepted by the mutation and then fails asynchronously: the batch ends in failed with the reason recorded, and the callback carries it in error. Count your rows before submitting; split a larger load into several batches.
An empty file (no data rows) fails the same way.
What is not supported
| Not supported | What happens if you try |
|---|---|
| Connected fields (connector fields pointing at another pipe or database) | Do not map them. A connector field mapped in fieldColumns does not create the connection between records: the cell value is written into the field as raw text, leaving the field holding a value that does not correspond to a real connection. There is no supported way to create or change a card connection through batchUpsert |
| Attachment fields | Do not map them. A batch cannot upload a file per row |
matchBy on an unsupported key | Only single-value field slugs present in the repo and the reserved keys $record_id and $title are accepted. Three things are rejected at submission, each with an error naming the offending key: any other $ key ($due_date, for example), a field slug that does not exist in the repo, and a field that holds more than one value (labels, assignees, checklists, connections, attachments). There is no way to match on the card's own labels, assignees, or due date either, since those are card attributes rather than fields and have no matchBy key |
id type fields as a write target | An id type field is read-only. Mapping one is ignored on write; it can only be used as a matchBy key |
statusColumn | The argument is accepted by the schema but never applied. A record's status is not written by a batch |
| Moving a card between phases | There is no phase column. batchUpsert writes field values and card attributes; it never moves a card |
| Deleting cards or records | The operation is upsert only |
| xlsx and other spreadsheet formats | format accepts csv and json only |
| Nested JSON values | A mapped JSON key whose value is an object or a nested structure is not usable. It is stringified into a meaningless cell value rather than being rejected, so point mapped keys at scalar values (or arrays of scalars) only |
| A file from another organization | Rejected with PERMISSION_DENIED. file.sourceFile must resolve to a key under your own orgs/<uuid>/ path |
Errors
Rejected at submission (the mutation returns an error, no batch is created)
| Condition | Error |
|---|---|
| Caller is not a Service Account | PERMISSION_DENIED |
| No permission on the target repo, or the repo does not exist | PERMISSION_DENIED (identical message in both cases, so the mutation does not reveal whether a repo exists) |
file.sourceFile is outside your organization's storage path | PERMISSION_DENIED |
A fieldColumns slug is not a field of the repo | INVALID_INPUT, naming the missing slugs |
| The source file is larger than 50 MB | INVALID_INPUT |
| The source file cannot be read from storage right now | INVALID_INPUT, retryable |
createIfNotFound: false with no matchBy | INVALID_INPUT |
matchBy carries an unsupported $ key | INVALID_INPUT, naming the keys |
matchBy names a field the repo does not have | INVALID_INPUT, naming the keys |
callbackUrl is not a valid http/https URL | INVALID_INPUT |
A concurrent submission won the same idempotencyKey | UNIQUE_RESOURCE_ALREADY_EXISTS |
Fails the whole batch asynchronously (status failed)
failed)The mutation already returned success, so these surface through the callback or through batchOperations, with the reason in the callback's error.
- The file could not be downloaded, or no longer exists.
- The file is malformed (unparseable CSV or JSON, or a JSON root that is not an array of objects).
- A referenced CSV header is missing or duplicated.
- The file has no data rows.
- The file has more rows than the applicable limit.
- The target repo no longer exists.
Fails a single row (status completed_with_errors, or failed if every row fails)
completed_with_errors, or failed if every row fails)- A label id or an assignee id/email that does not resolve in the repo.
- An unparseable date, or a phone or currency value the formatter rejects.
- More than one card matches the row's
matchByvalues. - A value that violates a field's
uniqueconfiguration.
Every failed row appears in the error report with its file row number and its reason.
Key Notes
- Acceptance is not completion. The mutation returns as soon as the batch is accepted, with
status: "queued"andtotalCount: 0. Nothing has been written yet. - No processing-time guarantee. Elapsed time depends on row count, fields per row, side effects, and current queue load. There is no SLA. Use
callbackUrl; poll with backoff only as a fallback. - A file is the only source.
fileis required. There is no inline-items argument. - Row limits: 5,000, or 10,000 with both suppression flags on, enforced at parse time, so an over-limit file fails asynchronously rather than being rejected by the mutation. Files are capped at 50 MB.
- Partial success is normal. A batch that finishes as
completed_with_errorsapplied every valid row. Read the error report for the rest. - Idempotency. Submitting the same
idempotencyKeyfor the same repo returns the existing batch instead of creating a duplicate, which makes a retry after a network timeout safe. - Blank cells clear values. Only map the columns you actually intend to write. A mapped blank cell overwrites the current value with an empty one.
- Connected fields and unsupported
matchBykeys are out of scope. See What is not supported before designing your mapping. - Related operations:
batchOperationsto list and poll batches,reprocessBatchOperationto re-run only the failed items.

