Bulk Delivery

Export every record matching a list query to a single file — NDJSON, JSON, CSV, or Parquet. Bulk Delivery queues a background job that dumps the full result set to S3 and hands back a signed download URL. You're billed by the number of rows delivered.

Bulk Delivery exports an entire filtered dataset to a single file. Instead of paging through millions of records 100 at a time, you submit the same filters you'd use on a list endpoint, pick an output format, and the API runs a background job that writes every matching row to a file in S3. Poll the job, and when it's done you get a signed URL to download the result.

It's the right tool when you want a whole slice of the dataset — every loan in a state, every originator at a company, every property in a set of ZIPs — landed as one file for a warehouse load, an offline analysis, or a hand-off to another system. For interactive, page-at-a-time reads, use the list endpoints instead.

Bulk Delivery is available for six resources:

ResourceSubmitPollCancel
AgentsPOST /v1/agents/bulk-deliveryGET /v1/agents/bulk-delivery/{jobId}DELETE /v1/agents/bulk-delivery/{jobId}
OriginatorsPOST /v1/originators/bulk-deliveryGET /v1/originators/bulk-delivery/{jobId}DELETE /v1/originators/bulk-delivery/{jobId}
CompaniesPOST /v1/companies/bulk-deliveryGET /v1/companies/bulk-delivery/{jobId}DELETE /v1/companies/bulk-delivery/{jobId}
LoansPOST /v1/loans/bulk-deliveryGET /v1/loans/bulk-delivery/{jobId}DELETE /v1/loans/bulk-delivery/{jobId}
PropertiesPOST /v1/properties/bulk-deliveryGET /v1/properties/bulk-delivery/{jobId}DELETE /v1/properties/bulk-delivery/{jobId}
BranchesPOST /v1/branches/bulk-deliveryGET /v1/branches/bulk-delivery/{jobId}DELETE /v1/branches/bulk-delivery/{jobId}

Every endpoint behaves identically — only the entity and its columns differ. The examples below use agents; swap the path segment for any other resource.

Deliveries of up to 1,000 rows need no special setup. Above that, your organization needs the bulk-delivery.<resource> entitlement (e.g. bulk-delivery.agents). It's granted per resource and sets your row ceiling, any row filters, and which columns you're allowed to export. Without it, a submission that would exceed 1,000 rows returns 403 — pass limit at or below 1,000 to deliver a capped subset of a broader query, or contact support for a limit increase.

How credits work

Bulk Delivery is metered by the number of rows delivered — a bigger export costs more, scaling with the row count.

  • You're gated on the estimate at submission. Before queuing the job, the API estimates the result-set size (estimatedTotal) and requires enough balance to cover it. If you can't cover the worst case, submission returns 402 — the job never starts.
  • You're charged on the actual delivered count. Billing lands when the job completes, against the rows actually written (chargedCount), which can be lower than the estimate.
  • A failed job isn't charged. If the export fails, no credit is debited.

The completed job record reports exactly what you paid: chargedCount (rows billed), creditsCharged (credits debited), and creditLedgerId (the ledger entry).

Submit a job

Send a POST to the resource's bulk-delivery endpoint. The request body is the same filter shape as the list endpoint — flatFilters, advancedFilters, period, and sort all work exactly as documented in Pagination & Filtering — plus two delivery-specific fields:

curl -X POST https://api.modelmatch.com/v1/agents/bulk-delivery \
  -H "x-api-key: mm_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "flatFilters": { "state": "CA" },
    "format": "parquet",
    "fields": ["fullName", "city", "state", "transactionCount"]
  }'
Request fieldTypeDescription
formatstringRequired. Output format — ndjson, json, csv, or parquet. See Formats.
fieldsstring[]Optional column projection. Names are the camelCase response fields the list endpoint returns (e.g. city, interestRate). Intersected with your entitlement's allowed columns and the entity's full schema; if the intersection is empty, all allowed columns are delivered.
flatFilters / advancedFilters—Define the result set, same as a list query.
periodstringOptional time-window scope ("2024", "last12Months", "yearToDate", "allTime", …).
sortarrayOptional ordering of rows in the output file.
limitnumberOptional hard cap on rows delivered (and billed). The job delivers the first limit rows in sort order. Must be at or below your row ceiling — 1,000 without an entitlement, or the entitlement's maxRows with it.

Don't supply pagination — a job always returns the full matching set. Unlike a list call, you don't page through results: the filters define the slice and the entire slice is written to one file. That's the whole point of the feature.

A successful submission returns 202 Accepted with a job handle:

{
  "jobId": "job_abc123",
  "status": "queued",
  "entityType": "agents",
  "format": "parquet",
  "estimatedTotal": 18452,
  "maxRows": 100000
}
FieldDescription
jobIdHandle to poll for status.
statusqueued on submission.
entityTypeThe resource being exported.
formatEchoes the requested format.
estimatedTotalEstimated rows in the result set. Drives the credit gate — you need enough balance to cover it.
maxRowsThe hard ceiling for a single delivery. If your estimate exceeds it, submission is rejected before the job starts — see below.

Row limits

A single delivery is capped at maxRows. If the estimated result set is larger, the API rejects the request with 400 and a delivery_exceeds_max_rows error rather than queuing a job you can't complete:

{
  "error": "delivery_exceeds_max_rows",
  "estimatedTotal": 820000,
  "maxRows": 100000
}

Tighten your filters or split the export by a dimension (state, period, ZIP range) and submit multiple jobs.

Poll for status

Poll the GET endpoint with your jobId until status is completed (or failed):

curl https://api.modelmatch.com/v1/agents/bulk-delivery/job_abc123 \
  -H "x-api-key: mm_your_key_here"
{
  "jobId": "job_abc123",
  "entityType": "agents",
  "format": "parquet",
  "status": "completed",
  "estimatedTotal": 18452,
  "processed": 18452,
  "bytes": 7340032,
  "chargedCount": 18452,
  "creditsCharged": 185,
  "creditLedgerId": "ledg_xyz789",
  "startedAt": "2026-06-25T18:40:00.000Z",
  "completedAt": "2026-06-25T18:43:12.000Z",
  "resultsUrl": "https://..."
}
FieldDescription
statusqueued, running, completed, failed, or cancelled.
processedRows written so far — track it against estimatedTotal for progress.
bytesSize of the output written so far.
chargedCountDelivered rows that were billed. Present once the job completes.
creditsChargedCredits debited for the delivered rows. Absent until completion; absent if the billing call didn't land.
creditLedgerIdLedger entry id for the debit. Absent if billing didn't land.
startedAt / completedAtISO-8601 job timestamps.
resultsUrlThe signed download URL. Present once completed.
errorFailure reason. Present only when status is failed.

resultsUrl is re-signed on every status fetch, so always download using the URL from your most recent poll — an older signed URL will expire. Job records (and their files) are retained for 30 days; after that, GET /bulk-delivery/{jobId} returns 404.

Download the result

Once status is completed, fetch resultsUrl directly — it's a plain signed S3 link, no auth header needed:

curl -L "$(curl -s https://api.modelmatch.com/v1/agents/bulk-delivery/job_abc123 \
  -H 'x-api-key: mm_your_key_here' | jq -r .resultsUrl)" \
  -o agents.parquet

Cancel a job

Send a DELETE to the same path to cancel a queued or running job:

curl -X DELETE https://api.modelmatch.com/v1/agents/bulk-delivery/job_abc123 \
  -H "x-api-key: mm_your_key_here"

The job is marked cancelled immediately and the worker stops at its next page boundary. The response body is the job's settled status record. A delivery is billed once, at completion, so a cancelled job is never charged — no credits are debited and no partial file is written. Cancelling a job that has already finished (or was already cancelled) returns 409 and changes nothing.

Formats

Every format contains the same rows — the entity's list/summary record, one per row (optionally narrowed by fields). Pick based on how you'll consume the file:

formatMedia typeExtensionNotes
ndjsonapplication/x-ndjson.ndjson.gzGzipped newline-delimited JSON. Best for streaming row-by-row into a pipeline.
jsonapplication/json.jsonA single JSON array. Convenient for small-to-medium exports loaded whole.
csvtext/csv.csvRFC 4180. Best for spreadsheets and generic tabular tools.
parquetapplication/vnd.apache.parquet.parquetTyped columnar Apache Parquet. Scalar fields keep their types; nested fields are JSON-encoded strings. Best for warehouse loads and analytics.

Errors

All endpoints return the standard authentication errors (401, 403, 429). In addition:

StatusEndpointMeaning
400submitValidation failure, or the estimated result set exceeds your entitlement's maxRows (delivery_exceeds_max_rows, with estimatedTotal and maxRows).
402submitInsufficient credits — your balance is below the estimated cost. Body includes balance and cost.
403submitThe delivery exceeds the 1,000-row allowance available without the bulk-delivery.<resource> entitlement (entitlement_missing). Body includes entitlementKey, estimatedTotal, and maxRows.
403status / cancelThe job belongs to another caller — you can only poll or cancel your own jobs.
404status / cancelJob not found, or expired past the 30-day retention window.
409cancelThe job already finished (or was already cancelled) — nothing to cancel.
502submitThe job failed to launch (preflight, S3, or task-launch failure). Safe to retry.

A 402 is the one to handle gracefully — catch it, surface balance vs cost, and prompt the user to top up before retrying. For a 403 on submit, lower limit or narrow the filters to fit under 1,000 rows, or reach out to support for a limit increase rather than retrying.

On this page