Batch Requests
Submit up to 2,000 API calls at once, then download the results when they are ready
In short
POST https://api.schooldigger.com/batcheswith a JSON list of API paths. You get a batch id back right away.- SchoolDigger runs the calls for you, paced at half your plan's per-minute limit, so the batch leaves the other half for your live traffic.
- Poll the batch status, then download the results in parts of 20 as each part finishes.
- Each call counts as one API call against your plan, as if you made it yourself (a call retried after a server error may count twice). Submitting, polling and downloading are free.
- Available on paid plans (not Developer or DEMO). Use it for more than 100 calls, or for any job you don't want to pace yourself.
1. Submit a batch
POST /batches?appID=YOUR_APP_ID&appKey=YOUR_APP_KEY with Content-Type: application/json:
{
"items": [
{ "id": "a1", "path": "/v3.1/schools/064215006903" },
{ "id": "a2", "path": "/v3.1/districts/0622710" },
{ "id": "a3", "path": "/v3.1/schools?st=CA&q=oak&page=2" }
],
"callbackUrl": "https://example.com/hooks/sd-batch"
}
path: any GET path on the API, starting with/v2.or/v3.. Leave outappIDandappKey: the batch adds yours.id: your own key for the item, 1-64 characters, unique in the batch. Optional; it defaults to the item's position ("0","1", …). Ids are always returned as strings.callbackUrl: optional, https only. See Callback.
The response is 202 Accepted, with the status URL in the Location header:
{
"batchId": "b_7f3c2a9e1d4b",
"status": "queued",
"itemCount": 3,
"partSize": 20,
"partCount": 1,
"estimatedSeconds": 4,
"statusUrl": "https://api.schooldigger.com/batches/b_7f3c2a9e1d4b"
}
If any item is invalid, the whole batch is rejected with 400 and an errors array listing up to 10 bad items, so nothing runs half-valid. A batch also has to fit in your remaining daily or monthly quota when it is submitted.
2. Check status
GET /batches/{batchId}?appID=...&appKey=...
{
"batchId": "b_7f3c2a9e1d4b",
"status": "running",
"itemCount": 500,
"completedCount": 220,
"failedCount": 3,
"partSize": 20,
"partCount": 25,
"partsReady": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"submittedUtc": "2026-10-27T18:02:11Z",
"startedUtc": "2026-10-27T18:02:14Z",
"completedUtc": null,
"pausedUntilUtc": null,
"estimatedSecondsRemaining": 336,
"expiresUtc": null
}
status:queued,running,completed,canceled,failed(could not run at all) orexpired(results deleted after 72 hours).failedCount: items whose final status is not 2xx (a404for an unknown school, for example). The batch is stillcompleted; each item's result has the detail.partsReady: parts you can download now, even while the batch is still running.- While the batch is
queuedorrunning, aRetry-Afterheader says how many seconds to wait before polling again. pausedUntilUtcis set while the batch waits out an API outage or a used-up daily or monthly limit. It resumes on its own.- Only the appID that submitted a batch can see it.
3. Download results
GET /batches/{batchId}/results?part=N&appID=...&appKey=..., for N from 0 to partCount - 1. Each part holds 20 items in the order you submitted them, as gzipped NDJSON (one JSON object per line):
{"id":"a1","path":"/v3.1/schools/064215006903","status":200,"body":{ ...the API response... }}
{"id":"a2","path":"/v3.1/districts/9999999","status":404,"body":{"code":404,"message":"...","description":"...","help":"..."}}
bodyis the same JSON the call returns on its own, with the same plan fields, written on one line.- The part is sent with
Content-Encoding: gzip. Most HTTP clients unzip it for you; with curl add--compressed. - A part that isn't ready yet returns
409with the current status. After the 72-hour retention, results return410.
Cancel
DELETE /batches/{batchId}?appID=...&appKey=... stops a queued or running batch and returns its status. Calls already made still count, and their parts stay downloadable.
Limits and pacing
| Items per batch | Up to 2,000 |
| Items per result part | 20 (a full batch has 100 parts) |
| Batches per appID | 3 queued or running at once; they run one at a time |
| Results kept | 72 hours after the batch finishes |
| Plans | Paid plans. Developer and DEMO keys get 403. |
A batch uses at most half of your plan's per-minute limit, spread evenly, and slows down further if your own traffic is using more than its half. It starts within about a minute of submit. Typical run times:
| Plan limit | Batch rate | 500 calls | 2,000 calls |
|---|---|---|---|
| 100 calls/min | 50/min | 10 min | 40 min |
| 500 calls/min | 250/min | 2 min | 8 min |
Temporary API errors (timeouts, 5xx, 429) are retried for you. An item that still fails is recorded with its last status code, or 599 with an error message for a network failure.
If your app restricts referrers or IPs, the batch's calls carry the Referer you sent when you submitted (or, without one, your IP address), so the same restrictions pass.
Callback
With a callbackUrl, SchoolDigger POSTs the status JSON there when the batch is completed, canceled or failed, and retries after 1, 5 and 30 minutes if your server doesn't answer with 2xx. Polling still works either way.
The X-SchoolDigger-Signature header is sha256= followed by the hex HMAC-SHA256 of the raw body. The HMAC key is the lowercase hex SHA-256 of your appKey. To check it in Python:
import hashlib, hmac
def valid(raw_body: bytes, header: str, app_key: str) -> bool:
key = hashlib.sha256(app_key.encode()).hexdigest().encode()
expected = "sha256=" + hmac.new(key, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Errors
Errors use the API's usual shape, {"code","message","description","help"}.
| Code | When |
|---|---|
400 | Invalid batch (see errors), or a part out of range |
401 | Missing or invalid appID / appKey |
403 | The plan can't submit batches (Developer, DEMO) |
404 | No such batch for this appID |
409 | The part isn't ready yet |
410 | The results were deleted after 72 hours |
413 | The request body is too large |
429 | Not enough daily or monthly quota left for the batch, or 3 batches already queued or running |
503 | The key couldn't be checked right now; try again in a minute |
Examples
curl
CREDS="appID=YOUR_APP_ID&appKey=YOUR_APP_KEY"
curl -X POST "https://api.schooldigger.com/batches?$CREDS" \
-H "Content-Type: application/json" \
-d '{"items":[{"id":"a1","path":"/v3.1/schools/064215006903"},{"id":"a2","path":"/v3.1/districts/0622710"}]}'
curl "https://api.schooldigger.com/batches/b_7f3c2a9e1d4b?$CREDS"
curl --compressed "https://api.schooldigger.com/batches/b_7f3c2a9e1d4b/results?part=0&$CREDS"
Python
import json, time, requests
BASE = "https://api.schooldigger.com"
CREDS = {"appID": "YOUR_APP_ID", "appKey": "YOUR_APP_KEY"}
items = [{"id": nces, "path": f"/v3.1/schools/{nces}"}
for nces in ["064215006903", "341341003182", "360007702877"]]
batch = requests.post(f"{BASE}/batches", params=CREDS, json={"items": items}).json()
while True:
r = requests.get(f"{BASE}/batches/{batch['batchId']}", params=CREDS)
status = r.json()
if status["status"] not in ("queued", "running"):
break
time.sleep(int(r.headers.get("Retry-After", 30)))
for part in status["partsReady"]:
r = requests.get(f"{BASE}/batches/{batch['batchId']}/results",
params={**CREDS, "part": part})
for line in r.text.splitlines():
result = json.loads(line)
print(result["id"], result["status"])
JavaScript (Node.js 18+)
const BASE = "https://api.schooldigger.com";
const CREDS = "appID=YOUR_APP_ID&appKey=YOUR_APP_KEY";
const items = ["064215006903", "341341003182", "360007702877"]
.map(id => ({ id, path: `/v3.1/schools/${id}` }));
const batch = await (await fetch(`${BASE}/batches?${CREDS}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items })
})).json();
let status;
while (true) {
const r = await fetch(`${BASE}/batches/${batch.batchId}?${CREDS}`);
status = await r.json();
if (!["queued", "running"].includes(status.status)) break;
const wait = Number(r.headers.get("Retry-After") || 30);
await new Promise(res => setTimeout(res, wait * 1000));
}
for (const part of status.partsReady) {
const r = await fetch(`${BASE}/batches/${batch.batchId}/results?part=${part}&${CREDS}`);
for (const line of (await r.text()).split("\n").filter(Boolean)) {
const result = JSON.parse(line);
console.log(result.id, result.status);
}
}
/batch endpoint (one multipart/mixed request that waits for every call) still works but is deprecated, and is limited to 100 calls. New code should use /batches.
See the API reference and the Change Log. For a large part of the database, a bulk data download may be a better fit. Questions: api@schooldigger.com.