Build a curl command that runs on the machine you paste it into
A curl generator that quotes for bash, PowerShell and cmd, writes the fetch() equivalent beside it, and warns about the flags that quietly change the request you meant to send.
The API returns a 400. The payload on your screen is obviously correct — you can read it, it is valid JSON, the field names match the docs. You paste the command into a colleague’s terminal and it works. You paste it into a Windows box and it fails differently. Somewhere between the text you typed and the bytes that left the machine, something rewrote your request, and nothing in the error message says so.
That is the whole problem with curl. It is not a hard tool. What is hard is that a curl command is two languages sharing one line, and only the second one is HTTP. The shell reads the line first, decides where each argument starts and stops, expands what it feels like expanding, and hands curl an array of strings that may no longer resemble what you wrote. curl then faithfully sends exactly that.
cURL Command Generator is the new tool for the first language. You describe the request once — URL, method, headers, auth, body — and it emits a command that is quoted correctly for the shell you are actually going to paste it into, with the fetch() equivalent beside it and a warning wherever the obvious output would have been wrong.
Key Takeaways
| The habit | What the generator does instead |
|---|---|
-X POST beside a body | Leaves it out — data already selects POST, and -X suppresses curl’s method rewriting on a 301 |
-X HEAD for a headers-only check | Emits -I, because -X HEAD waits for a body that will never arrive |
-d for the payload | --data-raw, because -d deletes newlines and reads a leading @ as a filename |
Content-Type: multipart/form-data written by hand | Omits it, so curl writes the header with the boundary it generated |
| Relabelling bash output as “Windows” | Three real dialects — and an honest refusal where cmd.exe cannot express the value |
| Pasting the working command into the ticket | A second copy button that swaps every credential for a shell variable |
- One model, two outputs. The same request renders as curl and as
fetch(), so the translation step that usually introduces the bug does not happen by hand. - Warnings, never blocking. A half-written JSON body still produces a command; the tool says the body does not parse and builds it verbatim anyway.
- Nothing is uploaded and nothing is stored. No request is ever sent from the page, and the token you type is not written to
localStorage.
The same request, three shells
Here is the JSON POST preset, exactly as the tool emits it for bash and zsh:
curl --fail \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
--data-raw '{
"sku": "AB-12",
"qty": 2,
"note": "it'\''s urgent"
}' \
'https://api.example.com/v1/orders?dry_run=true'
Two details there are the entire reason a generator is worth using. The first is it'\''s — four characters where one apostrophe was typed. Inside POSIX single quotes nothing is interpreted, which is what protects a body containing $HOME or a regex or a Windows path, and the price is that an apostrophe cannot appear inside them at all. The only construction that works is close the run, escape the quote outside it, open a new run. It looks like a mistake. It is the correct and only answer, and it is the thing people get wrong by hand — the naive version splits the body into three shell words, curl sees one of them, and the server rejects a payload it never fully received.
The second is what is missing: there is no -X POST. Supplying data already selects POST. Writing it anyway is the standard tell of a generated command, and it is not harmless — -X also pins the method across a redirect, so a 301 that curl would normally follow with a GET is re-sent as a POST.
The same request for PowerShell is not a relabelling. The quote character survives, but an interior apostrophe is doubled rather than escaped, and the line continues with a backtick:
curl --fail `
-H 'Content-Type: application/json' `
--data-raw '{
"sku": "AB-12",
"note": "it''s urgent"
}' `
'https://api.example.com/v1/orders?dry_run=true'
Beside it, the tool prints the caveat that explains most “it worked on my Mac” bug reports: Windows PowerShell 5.1 and earlier do not re-escape double quotes when calling a native binary such as curl.exe. The command above is right for PowerShell 7+; on 5.1 the quotes inside the body may need doubling by hand. That is not fixable from a generator — but it is very fixable by saying it out loud.
And for cmd.exe, the same request produces no command at all:
cmd.exe cannot carry a newline inside an argument, and this value contains one. Use bash or PowerShell for this request, or put the body in a file and reference it with
--data-binary @file.
There is no quoting construct in cmd that carries a literal newline — not double quotes, and not a caret, which continues the command rather than embedding a newline in an argument. Every available guess (drop it, turn it into a space, emit \n and hope curl expands it, which curl does not) produces a command that runs and sends the wrong bytes. A tool that hands you a plausible broken command is worse than one that tells you the truth.
cmd is not a second-class citizen, though. Anything expressible, it expresses — including the backslash rules of the Microsoft C runtime, which is what actually parses curl.exe’s arguments:
curl ^
-H "Content-Type: application/json" ^
--data-raw "{\"query\":\"query { viewer { login } }\"}" ^
"https://api.github.com/graphql"
Notice that the GraphQL mode works on cmd where a hand-written JSON body does not. The document you type keeps its line breaks for readability; the tool wraps it in the JSON envelope GraphQL-over-HTTP actually requires, where those line breaks become \n escapes inside a string. Same request, no literal newline, cmd-safe.
The flags that mean something other than what they look like
Most of the value in the tool is a handful of decisions it makes that a form-to-string generator would not:
-I, not-X HEAD.-Xchanges the method but leaves curl expecting a response body, so the transfer hangs until it times out.-Iis the flag that means what people mean by HEAD.--data-raw, not-d.-dstrips carriage returns and newlines from whatever it is handed, and reads a leading@as a path on disk. Both silently change the payload. If you switch to-d— the one good reason is a curl too old to know--data-raw, which landed in 7.43 back in 2015 — the tool warns you when your specific body would be affected.-H 'Name;'for an empty header. The intuitive spelling,-H 'Name:', is curl’s syntax for removing a header it would otherwise send. The two differ by one character and do opposite things.--data-urlencodeper form field, so curl does the percent-encoding: a password containing&or=arrives intact instead of splitting into two fields.- No hand-written
Content-Typeon a multipart request. curl generates a unique boundary and writes the header itself; a hand-written one has no boundary parameter, and the server cannot find where the parts begin. The tool warns if you add the row anyway — because overriding a header deliberately is a legitimate thing to be testing, and nothing here is blocked, only flagged. - Query parameters merged into the URL, encoded, and inserted before any
#fragmentrather than after it.
There are security warnings on the same footing. A URL with no scheme, because curl guesses http:// for a bare host and your token goes out in clear text. A credential over plain http://, where a redirect to HTTPS does not save you — the first request has already left. -k, which does not fix a certificate error so much as delete the check that produced it. And -u, which is what everyone reaches for and which puts the password in the process list where ps can read it; the pre-encoded Authorization: Basic header is offered right beside it as the same credential without that exposure.
One more, easy to miss and nasty when it bites: a carriage return or line feed pasted into a header value is a request-splitting payload, whether or not anyone meant it that way. Those characters end a header line in HTTP. The tool strips them and tells you it did, rather than writing out a command whose shape it never described.
The fetch() beside it comes from the same model
Getting a curl command to work is rarely the goal. The goal is usually the code, and the hand-translation from one to the other is where a working request stops working. So the tool renders the same resolved request as a fetch() call:
const response = await fetch('https://api.example.com/v1/orders?dry_run=true', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
"sku": "AB-12",
"qty": 2,
"note": "it's urgent"
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const data = await response.json();
A valid JSON body comes back as JSON.stringify({…}) — the form you would have written, and the one where your editor flags a missing comma. A body that does not parse stays a string literal, exact to the character, because a payload that does not parse is a payload whose bytes are the point. A form body becomes URLSearchParams, a file upload becomes FormData — where the browser sets the multipart boundary exactly as curl does — and --max-time becomes AbortSignal.timeout(). Where curl reads a file from disk with @./report.pdf, the generated line says so in a comment instead of pretending a web page has a filesystem:
const body = new FormData();
body.append('file', file); // curl read ./report.pdf; supply a File from an <input type="file">
The if (!response.ok) block is not filler. fetch() does not reject on a 404 or a 500 — it resolves happily and you parse an error page as JSON three lines later. Every emitted snippet carries the check.
The copy you can actually paste in a ticket
Generated commands end up in pull requests, incident channels and vendor support forms. That is what they are for, and it is also how live tokens end up in searchable systems that nobody audits.
So there is a second copy button. It emits the identical command with every credential replaced by a shell variable name:
curl -s --fail \
-H 'Accept: application/json' \
-H 'Authorization: Bearer $API_TOKEN' \
'https://api.example.com/v1/me'
Shell variables rather than *** on purpose: the person on the other end can export API_TOKEN=… and run the command unchanged. The scheme word survives too — Bearer $API_TOKEN, not $API_TOKEN — because dropping it produces a redacted command that fails in a way that looks like a server problem and costs someone an hour. Authorization, Proxy-Authorization, Cookie, X-API-Key and the usual family are recognised by name; basic auth keeps its username and loses its password.
If what you are about to share is a log or a trace rather than a command, PII Sanitizer does the same job for the file.
Where it runs
In your tab, and nowhere else — which for this tool is less a privacy slogan than a design constraint. The generator never sends the request it builds. There is no proxy, no “try it” button, no server round-trip; building a command is pure string work, and the one thing a page holding your bearer token should never do is make an outbound call.
Nothing is persisted either. The primary input here is an Authorization header, so there is no localStorage, no query-string state to accidentally paste into a chat, and no prefill restore. Closing the tab is what disposes of the token. That is the same rule the rest of the site runs on — see the privacy-first manifesto for why it applies to every tool in the DevOps collection, and online JWT decoders that log your secrets for what happens when a tool breaks it.
A workflow that holds up
- Start from a preset. Six of them — JSON POST, bearer GET, form login, file upload, GraphQL, basic auth — each a complete request rather than a snippet, because a token with no URL to send it to teaches nothing.
- Paste your rows. Headers, query parameters and form fields are name/value grids, and a block copied out of a browser’s network panel splits into rows on the first separator of each line. Untick a row to keep it without sending it.
- Pick the shell you will paste into, not the one you are on. The command is being written for a machine that may not be this one.
- Read the warnings before the command. They are the part that is hard to see by eye in your own line.
- Copy redacted when it leaves your machine. Copy plain when it does not.
- Take the
fetch()when you move from debugging to code, so the translation step never happens by hand.
Two neighbours worth knowing: JSON Formatter if the body needs fixing before it goes in a command, and URL Encoder when the question is what a single parameter should look like rather than what the whole request should.
Conclusion
The reason curl commands break is almost never curl. It is the quoting rules of a shell you are not currently standing in, plus four or five flags that do something adjacent to what their name suggests. Both are entirely knowable and neither is interesting, which is exactly the kind of thing worth handing to a tool.
cURL Command Generator writes the command for the shell you name, refuses to guess where a shell genuinely cannot express your value, points out the flag that is about to change your request, gives you the fetch() for free, and hands you a version that is safe to paste in public. It sends nothing, stores nothing, and works with the network disconnected.
Frequently Asked Questions
Does the tool send the request it builds?
No. It never makes a network call of any kind — there is no proxy, no “run it” button and no server component. It turns a description of a request into text, in the page. That is deliberate rather than incidental: the main input is an Authorization header, and a page holding your token has no business making outbound calls.
Is my token stored anywhere?
No. There is no localStorage, no session storage, no URL state and no prefill restore in this tool, specifically because of what people type into it. Closing the tab disposes of everything. The one concession to the fact that generated commands travel is the redacted copy, which replaces each credential with a shell variable name.
Why does my command look different on Windows?
Because the three shells disagree about the two things that matter here: which character quotes literally, and how a line is continued. bash and zsh use single quotes and a trailing backslash; PowerShell uses single quotes with doubled interior quotes and a backtick; cmd.exe uses double quotes with C-runtime backslash escapes and a caret. A “copy for Windows” button that only relabels bash output is worse than none, so each dialect is rendered properly — including a warning about PowerShell 5.1, which does not re-escape double quotes when calling curl.exe.
Why won’t it give me a cmd.exe command for my JSON body?
Because your body contains a real newline and cmd.exe has no way to carry one inside an argument. Every workaround produces a command that runs and sends different bytes than the ones on screen, which is the worst possible outcome for a debugging tool. Use bash or PowerShell for that request, or put the payload in a file and reference it with --data-binary @file.
Why is there no -X POST in the output?
Because supplying a body already selects POST, so it is redundant — and -X is not inert. It also suppresses the method rewriting curl performs on a 301 or 302, so a redirect that should have been followed with a GET gets re-sent as a POST. The generator emits an explicit method only where it changes the outcome, such as a GET carrying a body.
Can I still build a request the tool disagrees with?
Yes, with one exception. Everything the tool considers wrong — an invalid JSON payload, a hand-written multipart Content-Type, -k, a credential over plain HTTP — is emitted anyway with a warning beside it, because deliberately odd requests are normal while debugging. The only hard failure is a value the target shell cannot represent at all, where there is no correct command to emit.
Does the fetch() output match the curl command exactly?
It is the same resolved request, with two unavoidable differences. Basic auth becomes an Authorization header, since there is no -u equivalent that does not put the credential in the URL. And a file read from disk with @path becomes a FormData append with a comment naming the file, because a web page has no filesystem to read it from.