Docs menu
- Docs
- Guides
- Send many URLs as one batch
Send many URLs as one batch
Send up to 10,000 URLs in one call, get an id back, poll it, and download each result from a link that is signed for 15 minutes. Nothing is charged at submit. Each URL is priced and charged on its own, when a worker reaches it.
Use a batch instead of POST /v1 when you have many URLs and no one is
waiting for the answer. POST /v1 keeps a connection open per URL; a batch
keeps none. The batch API lives at /v1/batches, on the API host,
https://api.megalania.io. Every example below reads only MEG_API_KEY. Batches do not
live on the scrape host (https://get.megalania.io): POST /v1 is one
request in and one answer out, a batch outlives the connection that made it.
1. Submit the batch
The body is the POST /v1 envelope, shared by the whole batch, plus urls.
url is not a key here: the URLs are in urls.
curl -s -X POST "https://api.megalania.io/v1/batches" \
-H "Authorization: Bearer $MEG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)" \
-d '{
"urls": [
"https://lab.megalania.io/paged?page=1",
"https://lab.megalania.io/paged?page=2",
"https://lab.megalania.io/paged?page=3"
],
"engine": "http",
"country_code": "de",
"webhook_url": "https://hooks.example.com/megalania"
}'{ "id": "0199a1c2-...-6f31", "item_count": 3, "status": "queued" }| key | values | default |
|---|---|---|
urls |
1 to 10,000 absolute http or https URLs |
required |
deadline |
RFC 3339, at least 15 minutes and at most 30 days ahead | none |
webhook_url |
one absolute URL we POST the outcome to at the end |
none |
| everything else | any POST /v1 parameter, applied to every URL |
— |
Things to know:
- Every URL goes through the same check as a sync request. One bad URL, one
unknown parameter, or one address in a private, loopback, link-local,
multicast or broadcast range — in
urlsor inwebhook_url— is a 400 naming the field, and no batch is stored. For a URL the message names the entry number, counting from one, and never the URL itself. Fix the list and send it again. Idempotency-Keyworks here too, and a retry is safe. The first call with a key answers 202 and creates the batch. Any later call with the same key inside 24 hours answers 200 with the same body — the sameid,item_countandstatus— and creates nothing, so a retry that lost its answer still gets the batch back. Send the header: a resubmit without one is a second batch and a second bill.- A
deadlineonly moves work earlier. Items are scheduled to start five minutes before it. It is not a promise and not a refund. - A
deadlinedoes not change the price. Every page is priced as sync. - The cap is 10,000. More is a 400 that says so. Split the list.
Price the batch first
POST /v1/batches/quote takes the same body and stores nothing. It answers
{"each": 75, "total": 150}: the most one URL can cost and the most the whole
batch can cost, in credits. It is the number a worker reserves for each URL,
so max_credits set to each is never too low at the time of the quote.
curl -s -X POST "https://api.megalania.io/v1/batches/quote" \
-H "Authorization: Bearer $MEG_API_KEY" \
-H "Content-Type: application/json" \
-d '{"urls": ["https://lab.megalania.io/paged?page=1", "https://lab.megalania.io/paged?page=2"], "proxy_tier": "datacenter"}'Crawl a site from one URL
Add crawl and send exactly one URL. We fetch that page, read its links, and
add each new page on the same site as a new item. A crawl is a batch like any
other: poll it, page its items and download results the same way.
curl -s -X POST "https://api.megalania.io/v1/batches" \
-H "Authorization: Bearer $MEG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid)" \
-d '{
"urls": ["https://lab.megalania.io/"],
"crawl": {
"max_depth": 2,
"max_pages": 100,
"include_paths": ["/a", "/b", "/b/**", "/private/**"],
"exclude_paths": ["/paged"],
"respect_robots": true
}
}'On the Lab this finds /a, /b and /b/c. It skips /private/x, because
robots.txt disallows it.
{ "id": "0199a1c2-...-6f31", "item_count": 1, "status": "queued" }| key | values | default |
|---|---|---|
crawl.max_depth |
0 to 10. Links are followed this many clicks away from the first page | 2 |
crawl.max_pages |
1 to 10,000. The most items the batch will ever have, the first page included | 100 |
crawl.include_paths |
Up to 20 path patterns, each starting with / and 1 to 256 characters. Only links whose path matches one are followed |
none: every path |
crawl.exclude_paths |
Up to 20 path patterns, same form. A link whose path matches one is never followed, even if it matches include_paths |
none |
crawl.include_subdomains |
true also follows subdomains of the first page’s host (www. removed first) |
false |
crawl.respect_robots |
true skips links the site’s robots.txt disallows for User-agent: * |
false |
A bad value is a 400 bad_parameter that names the field, like crawl.include_paths.
Things to know:
item_countgrows while the crawl runs. It starts at 1. A new item appears when a finished page links to a page the batch does not have yet.- Only the same site is followed: the same scheme, host and port as the
page the link is on. A link to another host, a subdomain, another port, or
a private address is skipped. Only
<a href>links count. Withinclude_subdomains, a link to the first page’s host or any of its subdomains is followed too, on the same scheme and port. Another site never is. On a shared host such asgithub.io, every site under it is a subdomain, soinclude_subdomainsfollows all of them.max_pagesstill bounds the crawl and each page is charged. - Path patterns match the path only, never the host or the query.
*matches inside one path segment and**matches across segments, so/blog/*matches/blog/abut not/blog/a/b, and/blog/**matches both. Every other character matches itself. The first page is always fetched; the patterns decide which links found on pages are followed. - Path patterns are kept only while the crawl runs. They are cleared when the batch finishes or is canceled.
respect_robotsis free. We readrobots.txtonce per host per batch and never charge for it. Each read is logged with the batch, at 0 credits.AllowandDisallowuse the longest match, with*and$. A missing or brokenrobots.txt, or one we cannot reach in a few seconds, allows everything.- A skipped link costs nothing. It is never fetched and never becomes an item.
robots.txtand sitemap files are never pages. A link to/robots.txtor to asitemap*.xml(or.xml.gz) file never becomes an item, so it is never charged.- Each page is priced and charged on its own, like a batch item. Your
balance is checked page by page, so a crawl stops adding work you cannot
pay for. At most
max_pagespages are ever charged. - A page is fetched once per batch. The same link found on many pages is one
item. The part after
#is ignored. - Links come from the page’s HTML, whatever you ask for. Send any
response_types, or aschema: each item’s result is what you asked for, and the links are still read from the HTML at no extra cost. At most 1,000 links are read from one page. - Cancel stops the crawl. No new items are added after a cancel.
- Each item in
GET /v1/batches/{id}/itemshas adepth: 0 for the first page, 1 for the pages it links to, and so on. It isnullfor a batch item.
2. Poll the batch
curl -s -H "Authorization: Bearer $MEG_API_KEY" "https://api.megalania.io/v1/batches/$BATCH_ID"
{
"id": "0199a1c2-...-6f31",
"name": null,
"status": "running",
"params": { "engine": "http", "proxy_tier": null, "response_types": ["html"] },
"webhook_url": null,
"item_count": 3,
"done_count": 2,
"free_count": 0,
"failed_count": 0,
"deadline": null,
"created_at": "2026-09-09T09:12:44Z",
"finished_at": null,
"erased_at": null,
"webhook_status": null,
"counts": {
"queued": 0, "running": 1, "done": 2, "failed": 0, "blocked": 0,
"target_error": 0, "needs_js": 0, "canceled": 0,
"charged_credits": 2, "reserved_credits": 1, "returned_credits": 0
}
}Each item is in exactly one count, so counts adds up to item_count.
done and done_count are the items delivered with a result. blocked
(the site refused us), target_error (the site answered 5xx) and needs_js
(the page needs engine=browser) are free and never in done; together they
are free_count. done_count + free_count + failed_count plus canceled items
is item_count once the batch ends.
status is queued, running, done, failed or canceled. A batch whose
items all ended free is still done. Poll every few
seconds, or give a webhook_url at submit and skip the polling.
params.response_types lists what each item stores, ["html"] when you did
not set it.
webhook_status is pending, delivered or dead once we gave up on your
receiver, and null when the batch has no webhook_url.
3. Read the items and download the results
curl -s -H "Authorization: Bearer $MEG_API_KEY" \ "https://api.megalania.io/v1/batches/$BATCH_ID/items?limit=100"
{
"items": [
{
"id": "0199a1c3-...-11a0",
"position": 1,
"depth": null,
"url": "https://lab.megalania.io/",
"status": "done",
"credits_charged": 1,
"target_status": 200,
"engine": "http",
"proxy_tier": "datacenter",
"bytes": 18432,
"block_kind": null,
"document": null,
"document_units": 0,
"document_truncated": false,
"hops": [{"rung": "h:dc", "identity": "firefox", "ms": 412}],
"untried": null,
"untried_reason": null,
"cache": null,
"finished_at": "2026-09-09T09:13:05Z",
"error": null,
"result_url": "https://…/results/…?X-Amz-Expires=900&…",
"screenshot_url": null
}
],
"next_cursor": "1"
}Walk the pages by sending next_cursor back as cursor until it is null:
cursor=""
while : ; do
page=$(curl -s -H "Authorization: Bearer $MEG_API_KEY" \
"https://api.megalania.io/v1/batches/$BATCH_ID/items?limit=100&cursor=$cursor")
echo "$page" | jq -r '.items[] | select(.result_url) | .result_url' |
while read -r url; do curl -sO "$url"; done
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -n "$cursor" ] || break
doneThings to know:
result_urlpoints at our result store’s public host, not at the API, so it is a different address fromhttps://api.megalania.io.result_urlis signed for 15 minutes, and only a finished item has one. Ask for the page again to get a fresh link. It carries its own credentials, so fetch it without your token. It is signed forGETonly: aHEADon it answers 403.screenshot_urlis signed for 1 hour on every read, only on a finished item that asked forscreenshotand got one. The stored result keeps the screenshot’sbytesandcredits, never a link, so an old result still gets a working link. It downloads asscreenshot-<item id>-<date>.jpg.result_urldownloads a named file:batch-result-<item id>-<date>.<ext>, where the extension follows the result’s type (.md,.json,.html,.txt). The name never holds the page’s URL or host.- One type is one file of that type. Several
response_types, or ascreenshot, give one JSON object with a key per type (html,markdown,network, …) andscreenshot. Aschemagives the same JSON answer asGET /v1. positionis the item’s place in theurlsyou sent, starting at one, and it is whatcursorholds. The order never changes.limitis 1 to 1000, 100 by default.statuspicks items, comma-separated:pending,published,running,done,failed,canceled, and the kinds ofdone:delivered(it has a result), and the free ones by theirerror:blocked(the site refused us),target_error(the site answered 5xx) andneeds_js(the page needs the browser). For example?status=blocked,target_error,needs_js,failedlists what to look at again. A free item has noresult_url, and itserrorsays which.hopsis the routes we tried, in order, the same JSON array as theX-Hopsheader ofGET /v1:rung,identityandmsper try.untriedlists the routes your key may use that we did not try, only on a blocked orneeds_jsanswer.untried_reasonisbudgetwhen residential routes were unavailable, so the climb skipped residential; a skipped route is never charged.finished_atis when the item ended,nullwhile it waits or runs.cachesays the item was a copy. An item served from a recent copy of the page has"cache": {"hit": true}. It isnullotherwise. See Reuse a recent copy.- A
max_creditsbelow an item’s price fails that item for free. Itsstatusisfailed, and itserrornamesmax_credits. - A
country_codeno route serves fails the item for free. Itserrorisbad_parameteroncountry_code. Countries lists what each route serves. - A batch without
country_codeuses your account’s default country from the moment you send it, and pays its premium if it has one. The batch’sparamsshow that country. See Countries.
4. List your batches
curl -s -H "Authorization: Bearer $MEG_API_KEY" \ "https://api.megalania.io/v1/batches?status=done&sort=credits&order=desc&limit=25"
{ "batches": [ { "id": "0199a1c2-...-6f31", "status": "done", "...": "..." } ], "next_cursor": "118.0199a1c2-...-6f31", "total": 143 }Each batch has the same fields as GET /v1/batches/{id}, except counts:
the list carries only the counts stored on the batch, done, free, failed and
charged_credits. Read the batch for queued, running, blocked, target_error, needs_js, canceled,
reserved_credits and returned_credits. total counts
every batch the filters match, not only this page. Send next_cursor back
as cursor, with the same filters and sort, until it is null.
| key | values | default |
|---|---|---|
status |
queued, running, done, failed or canceled |
every status |
engine |
http, browser, or auto for a batch that left engine to us |
every engine |
created_after, created_before |
RFC 3339 times; after is inclusive, before is not | none |
q |
1 to 128 characters the batch name contains, ignoring case |
none |
sort |
created or credits (credits charged) |
created |
order |
desc or asc |
desc |
limit |
1 to 100 | 50 |
A cursor belongs to its sort: a cursor from sort=credits sent with
sort=created is a 400 on cursor.
5. Cancel
curl -s -X POST -H "Authorization: Bearer $MEG_API_KEY" \ "https://api.megalania.io/v1/batches/$BATCH_ID/cancel"
{ "id": "0199a1c2-...-6f31", "status": "canceled", "canceled": 41, "returned_credits": 3075 }canceled is how many items this call stopped, and returned_credits the
credits held for them that come back to you. Items already running finish
and are charged; items that never started are not. While an item still runs,
status does not change yet; the batch turns canceled when that item ends.
An item whose credits were held but whose turn had not come gets them back
within seconds, when the queue reaches it, so a balance read right after a
cancel can still be low for a moment. Cancelling itself costs nothing.
6. Erase a finished batch
Erase needs an account key (meg_ak_...), because it cannot be undone.
Make one on the Keys page, or erase from the dashboard.
curl -s -X DELETE -H "Authorization: Bearer $MEG_ACCOUNT_KEY" \ "https://api.megalania.io/v1/batches/$BATCH_ID"
The answer is the batch, as GET /v1/batches/{id} shows it, with
erased_at set. We delete every stored result of the batch, every item’s
URL, and the request parameters you sent (headers, cookies, body, steps)
now; only engine, proxy_tier and response_types stay. Your balance does
not change.
Things to know:
- A scrape key cannot erase. It gets 403
wrong_scope. A scrape key can still cancel a batch. - Only a finished batch. A
queuedorrunningbatch answers 409batch_running. Cancel it first, and erase it once its status iscanceled. - It cannot be undone. Download links stop working, the screenshots are
deleted, and each item’s
url,result_urlandscreenshot_urlarenullfrom then on. - A second call is safe. It answers 200 and changes nothing.
7. The webhook
If you sent a webhook_url, we POST it once when the batch finishes, done
or failed. Nothing else is sent: an item finishing on its own is not an event.
POST /your/path HTTP/1.1
Content-Type: application/json
X-Megalania-Event: batch.completed
X-Megalania-Timestamp: 1757410442
X-Megalania-Signature: sha256=6f1c…9b2a
{
"id": "0199a1c2-...-6f31",
"status": "done",
"item_count": 3,
"done_count": 3,
"free_count": 0,
"failed_count": 0,
"finished_at": "2026-09-09T09:14:02Z"
}The body carries no result and no URL. Read the items when it arrives.
Check the signature
An owner gets the webhook secret with POST /v1/account/webhook-secret/rotate
(account key), or on the dashboard: Connect, Webhooks, Rotate and show. We show a secret once, when it is made; we never show it again. See
Your account, Webhook secret.
The signature is sha256= plus the HMAC-SHA256 of X-Megalania-Timestamp, a
dot, and the exact bytes of the body, keyed with that secret. Sign the raw
body, not a re-serialized copy of it, and compare in constant time.
For 24 hours after a rotation the old secret signs too, so the header carries
two values, newest first: sha256=<new>,sha256=<old>. Accept the delivery when
any one of them matches. Your receiver keeps working while you swap secrets.
Refuse a timestamp more than five minutes away from your own clock. Without that check a POST captured once can be replayed forever: the body of a finished batch never changes.
import hashlib, hmac, time
def valid(raw: bytes, stamp: str, header: str, secret: str) -> bool:
if abs(time.time() - int(stamp)) > 300:
return False
signed = stamp.encode() + b"." + raw
mine = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(mine, part) for part in header.split(","))import { createHmac, timingSafeEqual } from "node:crypto";
export function valid(raw: Buffer, stamp: string, header: string | null, secret: string): boolean {
if (Math.abs(Date.now() / 1000 - Number(stamp)) > 300) return false;
const mine = Buffer.from("sha256=" + createHmac("sha256", secret).update(`${stamp}.`).update(raw).digest("hex"));
return (header ?? "").split(",").some((part) => {
const theirs = Buffer.from(part);
return theirs.length === mine.length && timingSafeEqual(mine, theirs);
});
}From a shell, with the stamp, the raw body and your secret, this prints the
value one part of X-Megalania-Signature must equal:
STAMP=1757410000 BODY='{"id":"x"}' SECRET=a-secret
printf '%s.%s' "$STAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* /sha256=/'Send yourself a signed test event to check your receiver end to end:
Your account, Webhooks.
What we retry
Answer 2xx and we stop. Anything else, including a redirect, is a failure:
we do not follow redirects, and we give up on a request after 10 seconds. Five
deliveries at most, waiting 1 s, 5 s, 25 s and 125 s in between. After the
fifth the batch is marked dead and nothing else is sent for it.
A delivery can repeat after your 2xx if our answer was lost, so treat the
id as the key and make your handler idempotent.
The URL must name a public address, at submit and again at delivery time: a webhook to a loopback, private, link-local, multicast or broadcast address is refused.
What we store, and for how long
Two things worth knowing before you send a list:
- A batch and its items are deleted 7 days after the batch ends: the URLs, the counts and the credits per item, erased or not. Your credit ledger is separate and stays, so your balance still counts what you paid.
- Each result is deleted 7 days after it is saved, so at the latest 7 days after the batch ends. A batch that runs longer than 7 days loses its oldest results first: fetch them as they finish.
- The URLs of a batch are stored in our database while the batch runs, until that deletion, or at once when you erase the batch.
- Headers, cookies, a body and steps are kept only while the batch runs.
Once it ends, only
engine,proxy_tierandresponse_typesstay, so a retry from the dashboard sends each URL again as a plain GET. - A
result_urlorscreenshot_urlis a bearer link. Anyone who holds it can fetch that file until it expires, 15 minutes (1 hour for a screenshot) after we sign it. Treat it like a password: do not paste it into a ticket, a log, or a chat.
Your batches are yours alone
A batch id that belongs to another key answers 404, never 403, on every route. We do not confirm that somebody else’s id exists.
Errors
Same shapes and codes as /v1; see Error codes. The ones you will meet here:
| status | code | what to change |
|---|---|---|
| 400 | bad_parameter |
error.field names urls, deadline, webhook_url, cursor, limit, status, engine, created_after, created_before, q, sort, order, or the scrape parameter that is wrong |
| 401 | bad_token |
no key, a key in the wrong place, or a wrong, expired or deleted key; the message says which |
| 403 | wrong_scope |
an account key on submit, read or cancel; or a scrape key on erase, which needs an account key |
| 404 | not_found |
no batch with that id under your key |
| 409 | batch_running |
the batch is still queued or running: cancel it, then erase it |
| 413 | body_too_large |
the body was over 8 MB; split the batch |
| 503 | busy |
our store did not answer, or your first submit with this Idempotency-Key is still being saved; send the same request with the same key again |
One item limit: a page over 10 MiB ends that item with body_cap, free of
charge. POST /v1 takes pages up to 50 MB: fetch a bigger page there.