You have a PDF and three ways to hand it to Parse: a link, an upload, or a file ID from a request you made earlier. In our test, they returned identical Markdown. They don't cost the same in bytes, they don't fail the same way, and only one of them needs the file to be reachable from the internet.
The short answer: send a link when Spidra can fetch the file, upload it when the file is yours alone, and keep the file ID when you'll touch the same document again within a day. This article is for developers choosing an input method for a document pipeline. If you only want to parse one PDF, our single-document guide is the shorter path, and the Parse docs list every option.
Three questions settle it
Can Spidra fetch the file from a link? Is it 50 MB or less? Will you use it again within a day? Those three answers pick the route, and the rest of this post tests each one.
One document, three requests
We parsed the same three pages of "Attention Is All You Need", a 15-page paper, three ways: by its link, by uploading our own copy of the PDF, and by the file ID the upload gave back. (Download the PDF from the same link first and save it as attention.pdf.)
# three_routes.py
import os
from spidra import Spidra
client = Spidra(api_key=os.environ["SPIDRA_API_KEY"])
URL = "https://arxiv.org/pdf/1706.03762"
by_link = client.parse(URL, pages="1-3")
by_upload = client.parse("attention.pdf", pages="1-3")
by_id = client.parse(file_id=by_upload.file_id, pages="1-3")Same method, three ways of saying where the file is. To see what each one cost in bytes, we counted every request the SDK made, retries and polling included. This is the whole counter:
# count_bytes.py
import httpx
calls = []
original_send = httpx.AsyncClient.send
async def counting_send(self, request, **kwargs):
response = await original_send(self, request, **kwargs)
sent = int(request.headers.get("content-length", 0))
calls.append((request.method, request.url.path, sent, len(response.content)))
return response
httpx.AsyncClient.send = counting_sendSave it as count_bytes.py and add import count_bytes as the first line of three_routes.py. Then sum(c[2] for c in count_bytes.calls) is the size of the requests you sent (each request's declared Content-Length) and sum(c[3] for c in count_bytes.calls) the size of the responses that came back. Call count_bytes.calls.clear() between requests to measure them one at a time. Here's what we measured:
| Route | Requests | Sent | Received | Seconds | Credits |
|---|---|---|---|---|---|
| Link | 1 | 73 B | 9,629 B | 2.3 | 3 |
| Upload | 1 | 2,215,542 B | 9,575 B | 3.2 | 3 |
| File ID | 1 | 80 B | 9,575 B | 1.1 | 3 |
attention.pdf is 2,215,244 bytes, so the upload sent the file plus 298 bytes of framing. The link and the file ID sent under 100 bytes each. All three returned the same 9,088 characters of Markdown for the same 3 credits, so the route changes what you send, not what you get or what you pay. The seconds are one run each on one connection, so read them as a rough guide: the file ID was quickest, which fits, because there was nothing to fetch and nothing to send.
All three responses also carried a file_id, the link's included. That's what makes reuse possible.
Send a link when Spidra can fetch the file
The link is the least code, and your server never touches the file: Spidra fetches it. That's also its limit. The file has to be reachable from the internet without a login. The Parse docs don't describe a way to send credentials with a link, so a file behind a login belongs in the upload route.
Here's what the failures look like:
# ... client as before
from spidra import SpidraError
LINKS = [
"https://example.invalid/report.pdf",
"https://raw.githubusercontent.com/github/gitignore/main/does-not-exist.pdf",
"http://127.0.0.1:8000/report.pdf",
]
for link in LINKS:
try:
client.parse(link)
except SpidraError as e:
print(f"{type(e).__name__} {e.status}: {e}")Output:
SpidraValidationError 422: The link couldn't be reached.
SpidraValidationError 422: The link returned HTTP 404.
SpidraValidationError 422: Private and internal URLs are not allowed.Three different failures, three plain messages, and nothing charged. Each request sent under 100 bytes and was answered in under a second. The last one is worth noticing: Parse won't fetch private or internal addresses, so a file that only your network can reach can't go through the link route at all.
The case that surprised us is a link that works but isn't a PDF. We sent https://example.com/, a web page. It wasn't an error. Parse read it as HTML and returned completed, one page and one credit, with Markdown that began "This domain is for use in documentation examples without needing permission." If your pipeline can be handed a link to a landing page instead of a file, check doc.format in the result before you trust it.
And a link doesn't mean Parse keeps nothing. The response carried a file_id, and parsing page 1 from it worked straight afterwards, for 1 credit. Your server never touches the file, but for the next 24 hours Parse still holds a copy.
Upload when the file is yours alone
An upload sends the file itself, as multipart form data, up to 50 MB per request. That makes it the right route for anything that isn't on the web: a file a user just handed your backend, something generated on the fly, a document behind your firewall. It costs you the file's size in bytes, plus a few hundred bytes of framing: 298, in our table.
An upload fails in two ways. It can be something Parse can't read, and it can be too big. We tried a text file named not-a-pdf.pdf and a 55 MB PDF:
# ... client as before
from spidra import SpidraError
for path in ("not-a-pdf.pdf", "big-55mb.pdf"):
try:
client.parse(path)
except SpidraError as e:
print(f"{type(e).__name__} {e.status}: {e}")Output:
SpidraValidationError 422: This PDF couldn't be read. It may be damaged or not really a PDF.
SpidraError 413: The file is larger than 50 MB. Use POST /api/parse/uploads for files up to 500 MB.The text file was a 325-byte request, refused in under a second. The 55 MB file was a 55,001,900-byte request, and it took 143 seconds to be told no. That's about how long the whole transfer takes on our connection (the same bytes went up in 29.6 seconds as a prepared upload, below), so the 413 came after the upload, not before it. Check the size on your side before you send anything over 50 MB.
One thing is easy to miss: an estimate is an upload too. estimate_parse() charges nothing, but to count pages it needs the file, so it sends it: 2,215,544 bytes for our local copy, against 75 bytes for the same estimate by link. Estimate once, then parse by the file ID it gives you, and the file goes up once instead of twice.
The Python SDK also retries a 502 or 503 on its own, and each retry sends the whole file again. A link costs under a hundred bytes per attempt. A 40 MB upload costs 40 MB per attempt.
A file ID is a 24-hour handle, not storage
Every route leaves you a file_id. It comes from an earlier parse, from a free estimate, or from a prepared upload. Send it instead of the file and Parse works on the copy it already has, so you can ask for other pages, a different prompt or a schema without sending the document again.
# ... client as before
SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string"},
"authors": {"type": "array", "items": {"type": "string"}},
},
}
PROMPT = "The paper's title and the authors' names"
more = client.parse(file_id=by_upload.file_id, pages="4-5")
fields = client.parse(file_id=by_upload.file_id, pages="1", prompt=PROMPT, schema=SCHEMA)
est = client.estimate_parse(file_id=by_upload.file_id, pages="1-15")
print(fields.json)
Output:
{'title': 'Attention Is All You Need', 'authors': ['Ashish Vaswani', 'Noam Shazeer', 'Niki Parmar', 'Jakob Uszkoreit', 'Llion Jones', 'Aidan N. Gomez', 'Łukasz Kaiser', 'Illia Polosukhin']}Here's what each of those requests cost, with a fourth: page 1 parsed from the file ID the link had returned.
| Request | Sent | Received | Seconds | Credits |
|---|---|---|---|---|
| Pages 4-5, from the upload's ID | 80 B | 6,355 B | 1.1 | 2 |
| Title and authors with a schema, page 1 | 266 B | 3,582 B | 2.0 | 2 |
| Estimate for all 15 pages, by file ID | 83 B | 158 B | 0.7 | 0 |
| Page 1, from the link's ID | 78 B | 3,447 B | 1.0 | 1 |
None sent more than 266 bytes. The estimate said all 15 pages would cost 15 credits, and charged nothing.
Reuse saves sending the file. It doesn't save pages: pages 4 and 5 cost 2 credits however you ask for them, and the schema run cost 2 because AI tokens come on top of the page. How credits work has the rule.
The lifetime is the part to design around. Parse keeps a file for 24 hours, then deletes it. After that, or after you delete it yourself, the ID returns a 404 with the message "No file with that fileId. Files can be parsed again for 24 hours after upload." Treat a file ID like a cache entry, not a place to keep documents. Keep your own copy, and if you need the file after a day, send it again.
We didn't wait a day to watch one expire. The 24 hours come from the docs, and every ID we reused here was reused straight after it was created.
An ID can fail in two more ways. One that never existed and one we'd deleted a moment earlier returned the same 404, with the same message as above, for 64 bytes sent. And a prepared upload whose bytes were never sent returned a 409: "The file hasn't been uploaded yet. PUT it to the uploadUrl first."
To delete a file now instead of waiting:
# ... client as before
est = client.estimate_parse("attention.pdf")
print(client.delete_parse_file(est.file_id))Output:
{'status': 'deleted', 'fileId': 'd4ffd6cb-…'}Past 50 MB, prepare the upload
For files up to 500 MB, the upload happens in three steps: ask Parse for an upload address, PUT the bytes to it within 15 minutes, then parse by the file ID you were given. The Python SDK does all three in one call:
# ... client as before
file_id = client.upload_for_parse("big-report.pdf")
doc = client.parse(file_id=file_id, pages="1")
print(doc.status, doc.pages_parsed, "page,", doc.credits_used, "credit")Output:
completed 1 page, 1 creditThe first step, asking for an upload address, sent 46 bytes and came back 201 with an address that was good for 900 seconds. The PUT then sent all 55,001,619 bytes, and the two steps together took 29.6 seconds. Parsing the ID cost 78 bytes and 1 credit. The same 55 MB sent to Parse directly had taken 143.4 seconds, and been refused. That's one run on one connection, so treat the gap as a guide, not a benchmark, but it's the same bytes from the same machine.
The SDK reads the whole file into memory before sending it, so a 500 MB file takes 500 MB of memory. If you'd rather stream it, call the three REST endpoints yourself. And if you parse the ID before the bytes arrive, you get a 409, not a hang.
Each route breaks in its own way
Here's where each route fails, and what you see when it does. A link to a web page isn't on the list, because it doesn't fail: it parses as HTML.
| Route | Fails when | What you see |
|---|---|---|
| Link | Spidra can't reach it, it returns an error, or the address is private | 422: "The link couldn't be reached." or "The link returned HTTP 404." or "Private and internal URLs are not allowed." |
| Upload | The file is over 50 MB, or isn't a readable PDF | 413: "The file is larger than 50 MB. Use POST /api/parse/uploads for files up to 500 MB." or 422: "This PDF couldn't be read. It may be damaged or not really a PDF." |
| File ID | It never existed, you deleted it, its 24 hours ran out, or a prepared upload never got its bytes | 404: "No file with that fileId. Files can be parsed again for 24 hours after upload." or 409: "The file hasn't been uploaded yet. PUT it to the uploadUrl first." |
Treat every copy as something you'll have to delete
Whichever route you pick, assume Parse holds a copy of the document for the next 24 hours: all three of our requests came back with a file ID, the link's included, and that copy is what makes the ID work. If the document is sensitive, there are three habits worth having. Don't make a private file public just to give Parse a link: upload it. Delete the file when you're done instead of waiting for the day to run out. And never treat a file ID as storage, because the day does run out.
Match the route to where your files already live
If a user hands your backend a file, upload it. There's no link to give, and the file already passes through your server.
If your documents sit in object storage, a signed link with a short expiry should work, because to Parse it's just a link that opens without a login. We tested public links, not signed ones, so try one with a throwaway file first. Your servers never touch the bytes, and nothing stays public.
If a nightly job works through a shared folder, estimate each file first (the estimate stores it), then parse by file ID, or send the IDs as a batch. Our batch PDF guide builds exactly that.
If you're building a tool where people re-run a document with different options, keep the file ID for the session and parse from it. After 24 hours, upload the file again.
Try the three routes on a document of your own
Create a free account at app.spidra.io (300 credits, no card) and run three_routes.py with a PDF you already have. Start with an estimate to see the pages and the credits before you parse anything. The Python SDK docs cover the options we skipped.
