The same bench,
with no browser in the way.
Everything these pages do, a program can do too: one endpoint per tool, the file in the request body, the result in the response. It is the same code the pages run — the same qpdf, the same alignment, the same container format — reached through a different door.
Every tool is one POST to /api/tools/<tool>. Where the input is a single file, the file is the body and the options are in the query string, which is what makes a plain curl work. Where it is several files or a piece of text, the body is JSON. The answer is the result itself — a PDF, an image, a ZIP — so `curl -o` gives you the file; send Accept: application/json and you get the same thing base64'd with the extras alongside it.
GET /api/tools answers with this page as data: every endpoint, every parameter, its type and its default, plus the limits that apply to you right now. It is generated from the same contract as the reference below, so a client built from it cannot be out of date with the server answering it.
None of this needs an account. An API token raises the ceilings and nothing else: send it as Authorization: Bearer hushhh_token_… and the bigger of the two limits below applies. Tokens are made under Account → API tokens, and the token itself is shown once.
Anonymous: 25 MB per file and 60 calls per 15 minutes. With a token: 100 MB per file and 600 calls. A call that goes over gets a 413 that says which limit it hit and what the limit was — no truncation, ever, because half a PDF is worse than an error.
Errors are JSON with an `error` field. 400 means the request was wrong — a missing parameter, a value outside its list — and names what. 422 means the request was fine and the file was not: a PDF that needs a password, a range that does not fit the document, a wrong password. 413 is the size limit and tells you which one. 429 is the rate limit. 500 is ours.
?filename= names the result in the Content-Disposition, so a download keeps a sensible name. It never reaches any tool.
Add Accept: application/json to get it base64'd in a JSON envelope instead.
Removes the password and the restrictions from a PDF, leaving the document untouched. The first attempt is made without a password: a file with owner-only restrictions opens with no password at all, and the answer says whether one was needed.
The file itself, as the request body.
The resulting file.
passwordquery stringThe password the document opens with. Leave it out first: a PDF with owner-only restrictions needs none.
curl -X POST --data-binary @locked.pdf \
"https://hushhh.cc/api/tools/pdf-unlock?password=hunter2" \
-o unlocked.pdfJoins documents into one, in the order they appear in the array. Each entry can carry its own password and its own page range.
JSON: { "files": [ { "name": "…", "data": "<base64>" } ] }
The resulting file.
files[].passwordbody stringThe password for this file, if it has one.
files[].rangebody stringThe pages to take from this file. Defaults to all of them.
jq -n --arg a "$(base64 -w0 one.pdf)" --arg b "$(base64 -w0 two.pdf)" \
'{files:[{name:"one.pdf",data:$a},{name:"two.pdf",data:$b}]}' |
curl -X POST -H 'Content-Type: application/json' --data-binary @- \
https://hushhh.cc/api/tools/pdf-merge -o merged.pdfSplits a document three ways: every page as its own file, only the pages you name, or everything except them.
The file itself, as the request body.
A ZIP when mode=each, otherwise one PDF.
modequery enum default: eacheach: one file per page, returned as a ZIP. keep: only the pages in range. drop: everything except them.
one of:
each,keep,droprangequery stringWhich pages, for keep and drop. "end" is the last page, so "end-2" is the last three.
passwordquery stringThe password the document opens with. Leave it out first: a PDF with owner-only restrictions needs none.
curl -X POST --data-binary @report.pdf \
"https://hushhh.cc/api/tools/pdf-split?mode=keep&range=1-3,8" \
-o first-pages.pdfTurns pages clockwise, all of them or the ones you name.
The file itself, as the request body.
The resulting file.
anglequery enum default: 90Degrees clockwise.
one of:
90,180,270,-90rangequery stringWhich pages to turn. All of them if left out.
passwordquery stringThe password the document opens with. Leave it out first: a PDF with owner-only restrictions needs none.
curl -X POST --data-binary @scan.pdf \
"https://hushhh.cc/api/tools/pdf-rotate?angle=90&range=2-end" \
-o straightened.pdfConverts an image to WebP, JPEG, PNG or AVIF. Metadata is not carried over — a converted photo arrives without the coordinates it was taken at.
The file itself, as the request body.
The resulting file.
formatquery enum requiredWhat to convert to.
one of:
webp,jpeg,png,avifqualityquery int default: 821–100, for the lossy formats. Ignored for PNG, which is lossless.
curl -X POST --data-binary @photo.png \
"https://hushhh.cc/api/tools/image-convert?format=webp&quality=80" \
-o photo.webpCompares two texts and returns the edit script, or a unified diff that `patch` will apply. The alignment is Myers' algorithm, the same code the page runs.
A JSON body.
JSON.
abody string requiredThe first text.
bbody string requiredThe second text.
granularitybody enum default: lineWhat to compare by. Word and character return one flowing run of spans instead of rows, because at that grain a line number answers nothing.
one of:
line,word,characterignoreCasebody bool default: falseTreat upper and lower case as the same.
ignoreWhitespacebody bool default: falseTreat runs of spaces and tabs as one, and ignore them at the ends of lines.
formatbody enum default: jsonjson: the edit script. unified: a real unified diff, line granularity only.
one of:
json,unified
curl -X POST -H 'Content-Type: application/json' \
-d '{"a":"one\ntwo","b":"one\nTWO","format":"unified"}' \
https://hushhh.cc/api/tools/text-compareLocks a file under a password, or opens one that was locked. AES-256-GCM with the key from PBKDF2-SHA-256 over 600,000 rounds; the container is the one documented on the tool's page, so a file locked here opens there and the other way round.
The file itself, as the request body.
The resulting file.
directionquery enum default: locklock to encrypt, unlock to decrypt.
one of:
lock,unlockpasswordquery string requiredThe password. There is no recovery: a file nobody can open is a file you have lost.
filenamequery stringThe original name, stored inside the container and given back when it is opened.
curl -X POST --data-binary @taxes.pdf \
"https://hushhh.cc/api/tools/file-encrypt?password=correct-horse&filename=taxes.pdf" \
-o taxes.pdf.hushhhGenerates passwords or passphrases from the system's random source, with rejection sampling so no character is likelier than any other.
A JSON body.
JSON.
modebody enum default: characterscharacters for a random string, words for a passphrase from the 512-word list.
one of:
characters,wordslengthbody int default: 20Characters, in characters mode.
wordsbody int default: 5Words, in words mode.
separatorbody string default: -What goes between the words.
countbody int default: 1How many to generate in one call.
uppercasebody bool default: trueInclude A–Z.
digitsbody bool default: trueInclude 0–9.
symbolsbody bool default: falseInclude punctuation.
curl "https://hushhh.cc/api/tools/password?mode=words&words=5"Returns the MD5, SHA-1 and/or SHA-256 of a file, and answers the question a checksum is actually asked: does it match the one you were given?
The file itself, as the request body.
JSON.
algorithmsquery list default: sha256Which digests to compute, comma-separated.
one of:
md5,sha1,sha256expectedquery stringThe checksum you were given. The answer comes back with matches: true or false, compared case-insensitively.
curl -X POST --data-binary @ubuntu.iso \
"https://hushhh.cc/api/tools/file-hash?algorithms=sha256,md5"This is the one part of the bench where the honest answer changes. On a tool page the file is opened in your own tab and this server never sees it — that is the promise those pages make and it stays true. Call the API and you have uploaded the file, because there is no way to run qpdf on a document without the document. So the promise here is the next one down, and it is kept in code rather than in prose: the bytes live in this process's memory for as long as the request takes, they are never written to disk, never written to the log — the request logger is mounted behind this endpoint on purpose — and never put in the database. When the response is sent there is nothing left. The test suite checks the data directory byte for byte before and after a run of every operation.
The same one thing is counted as on the pages: which tool ran, whether it worked, and an IP address with its last part zeroed. Not the file, not its name, not its size, not the result.
Frequently Asked Questions
Q: So the API does upload my file?
Yes, and that is the whole difference between it and the pages. A tool page runs the work in your browser and this server never sees the file; the API cannot do that, because the work happens here. What it does instead is hold the bytes in memory for the length of the request and never write them anywhere — not to disk, not to the log, not to the database — which is checked by a test that compares the data directory byte for byte around every operation. If that trade is not one you want to make for a particular file, the page for that tool does the same job without it.
Q: Do I need an account or a key?
No. Everything works anonymously, with smaller limits. A token raises the size and rate ceilings and nothing else — it does not unlock features and it does not change what is stored, which is still nothing.
Q: Is it the same code as the tool pages?
For the parts where it can be, yes, and deliberately: the same qpdf build with the same arguments, the same page-range reading, the same diff algorithm and the same container format, shared as modules rather than copied. Where it cannot be — the browser encrypts with WebCrypto and hashes by hand, this process has node:crypto — the two are pinned together by tests that make a file on one side and open it on the other.
Q: What happens if I go over a limit?
You get a 413 with the limit in it, or a 429 with the window. Nothing is ever truncated to fit: half a PDF that looks like a whole one is worse than an error.
Q: Can I send a batch?
pdf-merge takes several files in one call because joining is what it is for. Everything else is one file per call — which makes each call independent, retryable and easy to run in parallel from your side, where you know how much of the machine you want to use.
The same idea, applied to sending things rather than changing them.