← One-Page Dev Guides

curl: why your POST works in bash but not in a script

📅 2026-09-15 📊 beginner ["curl" "bash" "http" "apis"]

The Problem

You're wiring a deploy hook into a shell script. It creates a ticket when a release ships:

curl -s -X POST https://api.example.com/things \
  -H "Content-Type: application/json" \
  -d '{"name": "O'"'"'Brien deploy", "tag": "it works"}'

That's already unreadable — the ' escaping dance around the apostrophe is a hint. But the version that bites you is the plain one:

curl -s -X POST https://api.example.com/things \
  -H "Content-Type: application/json" \
  -d '{"name": "O'Brien deploy", "tag": "it's live"}'

Typed interactively you happened to use names without apostrophes, so it worked every time. The moment the script runs in CI with real user data — a ticket named after O'Brien, a note containing it's live — the API returns HTTP 400, or the ticket gets created with the name O and the rest of the JSON vanishes into shell errors like Brien: command not found. Debugging is miserable because the failing command isn't in your terminal history with its exact quoting — it's buried in a .sh file that looks identical to the one that worked. Cost: a failed deploy notification, twenty minutes of squinting at logs, and a curl command that behaves differently depending on the data, not the code.

The Mistake

Embedding JSON inside single quotes (-d '{...}'). The shell treats the single quotes as literal delimiters, but the moment your JSON contains an apostrophe (a name like O'Brien, a comment with it's, or a SQL WHERE name = 'foo') the quote-pair breaks and the shell starts parsing the rest of the command as code.

The mental model is seductive because single-quoting is correct shell hygiene in every other case. It's how you protect $, spaces, and globs from expansion — you've been trained to reach for it. And it works, deterministically, right up until the data contains the one byte that single quotes cannot contain: an apostrophe itself. You can't escape a ' inside '...'; the string just ends there. So the approach feels safe because it is safe against everything except the exact character class JSON happens to borrow from English prose.

The Fix

Use --data-binary with a here-doc, or pipe the body via stdin:

curl -X POST https://api.example.com/things \
  -H "Content-Type: application/json" \
  --data-binary @- <<'EOF'
{"name": "O'Brien", "tag": "it's a test"}
EOF

Walk through it:

The <<'EOF' (with quotes around the delimiter) disables variable expansion inside the body, so $ characters and backticks stay literal.

Why It Works

The shell processes quoting at parse time, before the command even runs, and it processes it left to right. Inside '...' the shell treats every byte as literal, but the first unescaped ' ends the string — there is no escape sequence for an apostrophe within single quotes. After that closing quote, the shell is back in code context: Brien looks like a command, whitespace splits words, and globs expand. JSON often contains apostrophes, so single-quoting is fragile. Heredocs with a quoted delimiter bypass shell parsing entirely: the body is read as raw text and handed to curl's stdin untouched. Meanwhile --data-binary, unlike plain -d, posts the bytes with no processing — -d @file would strip carriage returns and newlines, which is its own silent body corruption. Two mechanisms, each covering the other's failure mode.

Copy-Paste

# Read JSON body from a file (cleanest)
curl -X POST https://api.example.com/things \
  -H "Content-Type: application/json" \
  --data-binary @body.json

# Edit body.json like a normal file — no shell quoting ever touches the JSON

Next Steps

Download PDF (1 page)

Get a fresh guide every Monday. £12/mo, £100/yr, or £250 lifetime.

See pricing →