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 |
|---|---|---|
| Agents | POST /v1/agents/bulk-delivery | GET /v1/agents/bulk-delivery/{jobId} |
| Originators | POST /v1/originators/bulk-delivery | GET /v1/originators/bulk-delivery/{jobId} |
| Companies | POST /v1/companies/bulk-delivery | GET /v1/companies/bulk-delivery/{jobId} |
| Loans | POST /v1/loans/bulk-delivery | GET /v1/loans/bulk-delivery/{jobId} |
| Properties | POST /v1/properties/bulk-delivery | GET /v1/properties/bulk-delivery/{jobId} |
| Branches | POST /v1/branches/bulk-delivery | GET /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.
Bulk Delivery requires the bulk-delivery.<resource> entitlement (e.g. bulk-delivery.agents) on your organization. It's granted per resource and controls both access and which columns you're allowed to export. If you don't have it, submission returns 403. Contact support to enable it.
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. |
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, or failed. |
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.parquetFormats
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 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 | Your organization lacks the bulk-delivery.<resource> entitlement. Body includes entitlementKey. |
403 | status | The job belongs to another caller — you can only poll your own jobs. |
404 | status | Job not found, or expired past the 30-day retention window. |
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, the resource isn't enabled for the org; reach out to support 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.