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:
| Resource | Submit | Poll | Cancel |
|---|---|---|---|
| Agents | POST /v1/agents/bulk-delivery | GET /v1/agents/bulk-delivery/{jobId} | DELETE /v1/agents/bulk-delivery/{jobId} |
| Originators | POST /v1/originators/bulk-delivery | GET /v1/originators/bulk-delivery/{jobId} | DELETE /v1/originators/bulk-delivery/{jobId} |
| Companies | POST /v1/companies/bulk-delivery | GET /v1/companies/bulk-delivery/{jobId} | DELETE /v1/companies/bulk-delivery/{jobId} |
| Loans | POST /v1/loans/bulk-delivery | GET /v1/loans/bulk-delivery/{jobId} | DELETE /v1/loans/bulk-delivery/{jobId} |
| Properties | POST /v1/properties/bulk-delivery | GET /v1/properties/bulk-delivery/{jobId} | DELETE /v1/properties/bulk-delivery/{jobId} |
| Branches | POST /v1/branches/bulk-delivery | GET /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 returns402— 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 field | Type | Description |
|---|---|---|
format | string | Required. Output format — ndjson, json, csv, or parquet. See Formats. |
fields | string[] | 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. |
period | string | Optional time-window scope ("2024", "last12Months", "yearToDate", "allTime", …). |
sort | array | Optional ordering of rows in the output file. |
limit | number | Optional 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
}| Field | Description |
|---|---|
jobId | Handle to poll for status. |
status | queued on submission. |
entityType | The resource being exported. |
format | Echoes the requested format. |
estimatedTotal | Estimated rows in the result set. Drives the credit gate — you need enough balance to cover it. |
maxRows | The 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://..."
}| Field | Description |
|---|---|
status | queued, running, completed, failed, or cancelled. |
processed | Rows written so far — track it against estimatedTotal for progress. |
bytes | Size of the output written so far. |
chargedCount | Delivered rows that were billed. Present once the job completes. |
creditsCharged | Credits debited for the delivered rows. Absent until completion; absent if the billing call didn't land. |
creditLedgerId | Ledger entry id for the debit. Absent if billing didn't land. |
startedAt / completedAt | ISO-8601 job timestamps. |
resultsUrl | The signed download URL. Present once completed. |
error | Failure 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.parquetCancel 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:
format | Media type | Extension | Notes |
|---|---|---|---|
ndjson | application/x-ndjson | .ndjson.gz | Gzipped newline-delimited JSON. Best for streaming row-by-row into a pipeline. |
json | application/json | .json | A single JSON array. Convenient for small-to-medium exports loaded whole. |
csv | text/csv | .csv | RFC 4180. Best for spreadsheets and generic tabular tools. |
parquet | application/vnd.apache.parquet | .parquet | Typed 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:
| Status | Endpoint | Meaning |
|---|---|---|
400 | submit | Validation failure, or the estimated result set exceeds your entitlement's maxRows (delivery_exceeds_max_rows, with estimatedTotal and maxRows). |
402 | submit | Insufficient credits — your balance is below the estimated cost. Body includes balance and cost. |
403 | submit | The delivery exceeds the 1,000-row allowance available without the bulk-delivery.<resource> entitlement (entitlement_missing). Body includes entitlementKey, estimatedTotal, and maxRows. |
403 | status / cancel | The job belongs to another caller — you can only poll or cancel your own jobs. |
404 | status / cancel | Job not found, or expired past the 30-day retention window. |
409 | cancel | The job already finished (or was already cancelled) — nothing to cancel. |
502 | submit | The 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.
Property Enrichment
Skip-trace a property to get owner contact info — names, phones, and emails. Enrich one property on demand, or queue a bulk job for up to 50,000. You're charged only for properties that return a match; cache hits and no-matches are free.
How to search the docs
Find answers in the Model Match docs with keyword search, Ask AI, or the documentation search MCP server.