Submit a Batch Upsert of Cards or Records

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 batchOperations query 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 processing and 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

  1. Authentication: Use a Service Account token (Personal Access Tokens are deprecated). The mutation rejects any non-service-account caller.
  2. Permissions: Your Service Account must be able to create/update cards in the target pipe or database.
  3. 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.
  4. 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

  1. Call createPresignedUrl with your organization ID and the file name.
  2. PUT the file bytes to the returned url.
  3. Keep the downloadUrl from that response. It is what you pass as file.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

FieldRequiredDescription
repoIdYesThe pipe or database whose cards/records are upserted
fileYesThe 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
matchByNoField 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
createIfNotFoundNoCreate a record when no match is found (default true). When false, matchBy is required
idempotencyKeyNoReturns the existing batch on a duplicate submission for the same repo (max 100 characters)
callbackUrlNoHTTP(S) URL notified when the batch finishes (max 500 characters). Strongly recommended
suppressAutomationsNoSuppress automations for this batch (default false)
suppressWebhooksNoSuppress webhooks for this batch (default false)

File source fields

FieldRequiredDescription
sourceFileYesURL of the uploaded CSV or JSON file (from createPresignedUrl)
formatYescsv or json. The format is always explicit: there is no detection from the file extension or its content
fieldColumnsYesField-to-column mapping. Each entry pairs a field slug (fieldId) with a column name (column)
startRowNoFirst data row, CSV only (default 2). The header line is read from the row immediately above it. Ignored for JSON
recordIdColumnNoColumn carrying the record id, used with matchBy: ["$record_id"]
titleColumnNoColumn carrying the card title (cards only)
assigneesColumnNoColumn carrying card assignee ids or emails, comma-separated (cards only)
dueDateColumnNoColumn carrying the card due date (cards only)
labelsColumnNoColumn carrying card label ids, comma-separated (cards only)
statusColumnNoAccepted 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

FieldDescription
batchOperation.idPublic identifier of the batch operation (a UUID). Keep it
batchOperation.operationTypeThe kind of batch operation (upsert_records)
batchOperation.statusCurrent lifecycle status. A newly accepted batch is queued
batchOperation.totalCountNumber of items in the batch. It is 0 on acceptance and is set once the file has been parsed
batchOperation.resultFileTemporary download URL for the error report; null until the batch produces errors
batchOperation.createdAtWhen the batch was submitted
batchOperation.finishedAtWhen the batch finished; null while it is unfinished
batchOperation.suppressAutomationsWhether automations were suppressed for this batch
batchOperation.suppressWebhooksWhether webhooks were suppressed for this batch
batchOperation.requestedByThe 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

When 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"
  }
}
FieldDescription
idThe batch operation UUID
statusThe terminal status
operation_typeupsert_records
finished_atWhen the batch finished, ISO 8601
total_countNumber of items in the batch
item_countsCount per item status, always carrying every status key
result_fileTemporary download URL for the error report, or null when the batch produced no failed row
errorPresent 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 http or https URL 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 reprocessBatchOperation produces 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 row and error, 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 at startRow.
  • 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.
  • startRow does 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, dueDateColumn clears the due date, and labelsColumn / assigneesColumn clear labels and assignees. A blank titleColumn resets the card to the default draft title.
  • recordIdColumn is 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 typeExpected cell value
Short text, long text, email, CPF, CNPJThe raw text
NumberThe numeric value
CurrencyA number; it is normalized to two decimal places
PhoneA phone number Pipefy can parse; between 4 and 15 digits after normalization, otherwise the row fails
Date, datetime, due dateA parseable date/time string. An unparseable or out-of-range value fails the row
Select, radioThe 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 (with createIfNotFound: 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:

KeyMatches on
A single-value field slugThat 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_idThe card/record id, taken from the column named by recordIdColumn
$titleThe card title, case-insensitive

Outcomes per row:

SituationResult
Exactly one card matchesThe row updates that card
No card matches, createIfNotFound: trueThe row creates a new card
No card matches, createIfNotFound: falseThe item is recorded as skipped
More than one card matchesThe item fails with a message naming the ambiguous key. Pipefy never picks one

Notes:

  • $record_id can 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 $title will 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.
  • matchBy is not validated against a field's unique flag. 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

LimitValueHow it is enforced
Rows per batch, side effects on5,000Default. Applies whenever suppressAutomations or suppressWebhooks is false
Rows per batch, side effects off10,000Applies only when both suppressAutomations and suppressWebhooks are true
Source file size50 MBChecked at submission (rejected as INVALID_INPUT) and again before the file is read
matchBy keys1 to 3Rejected at submission
idempotencyKey length100 charactersRejected at submission
callbackUrl length500 charactersRejected 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 supportedWhat 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 fieldsDo not map them. A batch cannot upload a file per row
matchBy on an unsupported keyOnly 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 targetAn id type field is read-only. Mapping one is ignored on write; it can only be used as a matchBy key
statusColumnThe argument is accepted by the schema but never applied. A record's status is not written by a batch
Moving a card between phasesThere is no phase column. batchUpsert writes field values and card attributes; it never moves a card
Deleting cards or recordsThe operation is upsert only
xlsx and other spreadsheet formatsformat accepts csv and json only
Nested JSON valuesA 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 organizationRejected 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)

ConditionError
Caller is not a Service AccountPERMISSION_DENIED
No permission on the target repo, or the repo does not existPERMISSION_DENIED (identical message in both cases, so the mutation does not reveal whether a repo exists)
file.sourceFile is outside your organization's storage pathPERMISSION_DENIED
A fieldColumns slug is not a field of the repoINVALID_INPUT, naming the missing slugs
The source file is larger than 50 MBINVALID_INPUT
The source file cannot be read from storage right nowINVALID_INPUT, retryable
createIfNotFound: false with no matchByINVALID_INPUT
matchBy carries an unsupported $ keyINVALID_INPUT, naming the keys
matchBy names a field the repo does not haveINVALID_INPUT, naming the keys
callbackUrl is not a valid http/https URLINVALID_INPUT
A concurrent submission won the same idempotencyKeyUNIQUE_RESOURCE_ALREADY_EXISTS

Fails the whole batch asynchronously (status 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)

  • 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 matchBy values.
  • A value that violates a field's unique configuration.

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" and totalCount: 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. file is 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_errors applied every valid row. Read the error report for the rest.
  • Idempotency. Submitting the same idempotencyKey for 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 matchBy keys are out of scope. See What is not supported before designing your mapping.
  • Related operations: batchOperations to list and poll batches, reprocessBatchOperation to re-run only the failed items.