Testing and Automating Requests
curl is the Swiss army knife for HTTP from the shell. Anyone who knows the right flags, parses JSON responses precisely with jq, handles authentication securely, and implements retry logic with exponential backoff can automate REST APIs directly in Bash, without external frameworks.
Table of Contents
- 1. curl as an API Testing Tool in Bash
- 2. The Most Important curl Flags for API Automation
- 3. Correctly Evaluating HTTP Response Codes
- 4. Parsing JSON Responses with jq
- 5. Authentication Methods in Bash API Scripts
- 6. Retry Logic with Exponential Backoff
- 7. Uploading and Downloading Files
- 8. Debugging and Logging curl Requests
- 9. curl Options Compared Directly
- 10. Summary
- 11. FAQ
1. curl as an API Testing Tool in Bash
curl in Bash is the most direct link between shell scripts and HTTP interfaces. Unlike Postman or Insomnia, which offer graphical interfaces, curl in Bash can be embedded fully into automation: CI/CD pipelines, monitoring scripts, deployment workflows, and health checks. A curl command is versionable, reproducible, and available in every Unix environment. That makes it the preferred tool for API testing in shell scripts.
The difference between occasional API testing with curl in Bash and professional API automation lies in how error scenarios are handled. A plain curl https://api.example.com/resource call without flags returns exit code 0 for almost any failure (network problem, timeout, invalid response) as long as the connection layer itself does not fail. Professional curl Bash automation separates the HTTP status code from the response body, implements timeouts and retry logic, and checks the response content against what is expected, not just the HTTP code.
Another aspect that sets curl in Bash apart from other HTTP clients is its native integration with Unix pipes. curl output can be piped directly through jq, grep, sed, or tee. Large responses are streamed instead of being held entirely in memory. That makes curl in Bash efficient even when processing large API responses.
2. The Most Important curl Flags for API Automation
The first flag in any curl Bash automation should be -sS: -s (silent) suppresses the progress bar and status messages, while -S (show-error) still shows error messages. Together, -sS produces clean output without noise but still fails clearly on real errors. -f (fail) makes curl exit with code 22 when the HTTP status is 4xx or 5xx, but without the body in the output. For more precise control, -w "%{http_code}" is better: the status code is appended to the end of the output and can be evaluated separately.
Timeouts are essential in curl Bash automation. --connect-timeout 5 limits the time allowed to establish a connection. --max-time 30 limits the total duration of the request including the response. Without these flags, a hanging server can block the script indefinitely. -L (location) follows HTTP redirects automatically, which is useful for APIs that redirect to HTTPS. --compressed enables automatic gzip/br decompression of the response. -X sets the HTTP method (GET, POST, PUT, PATCH, DELETE), though --data implies POST by default.
#!/usr/bin/env bash
# api_wrapper.sh: Production-ready curl wrapper for REST API automation
set -euo pipefail
API_BASE="${API_BASE:?Set API_BASE (e.g. https://api.example.com/v1)}"
API_TOKEN="${API_TOKEN:?Set API_TOKEN}"
# Core HTTP function: returns body, exits on non-2xx
http_request() {
local method="$1" path="$2"; shift 2
local url="${API_BASE}${path}"
local response http_code body
# Capture body and status code in a single curl call
response="$(curl -sS \
--connect-timeout 5 \
--max-time 30 \
-L \
--compressed \
-X "$method" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-w "\n%{http_code}" \
"$@" "$url" 2>&1)"
http_code="$(tail -n1 <<< "$response")"
body="$(sed '$d' <<< "$response")"
# Structured error output
if [[ ! "$http_code" =~ ^2 ]]; then
local error_msg
error_msg="$(jq -r '.message // .error // "Unknown error"' <<< "$body" 2>/dev/null || echo "$body")"
printf '[ERROR] HTTP %s %s %s: %s\n' "$http_code" "$method" "$path" "$error_msg" >&2
return 1
fi
echo "$body"
}
# Convenience wrappers
http_get() { http_request GET "$@"; }
http_post() { http_request POST "$@"; }
http_put() { http_request PUT "$@"; }
http_delete() { http_request DELETE "$@"; }
# Example: paginated list request
list_resources() {
local resource="$1" page=1 per_page=100
while true; do
local result
result="$(http_get "/${resource}?page=${page}&per_page=${per_page}")"
# Emit each item
jq -c '.[]' <<< "$result"
# Stop if fewer items than requested (last page)
local count
count="$(jq 'length' <<< "$result")"
(( count < per_page )) && break
(( page++ ))
done
}
3. Correctly Evaluating HTTP Response Codes
Correctly evaluating HTTP response codes in curl Bash scripts goes well beyond simply checking for 200. In production, 201 Created (POST succeeded, resource was created), 204 No Content (DELETE succeeded, no output), 401 Unauthorized (token missing or expired), 403 Forbidden (token valid but not authorized), 404 Not Found (resource does not exist), 409 Conflict (duplicate or state conflict), and 429 Too Many Requests (rate limit) are important distinctions that require different reactions in the script.
A robust curl Bash script handles 429 responses with automatic waiting (reading the Retry-After header), 401 responses with a token refresh where possible, and 5xx responses with retry logic and exponential backoff. 4xx responses other than 429 and 401 are generally not worth retrying and should abort immediately with a clear error message. This differentiated handling is what separates a fragile one-liner from a resilient curl Bash automation.
4. Parsing JSON Responses with jq
jq is the essential companion to curl in Bash for any API automation that works with JSON responses. jq -r '.field' extracts a string without quotes. jq -c '.' outputs compact JSON without whitespace, ideal for further processing in pipes. jq '.items[] | select(.status == "active") | .id' combines iteration, filtering, and field extraction in a single step. jq --arg key "$bash_var" '.items[] | select(.name == $key)' passes Bash variables to jq safely, without string interpolation inside the filter.
Common pitfalls when using jq in curl Bash scripts: jq returns exit code 0 even when the input is not valid JSON, in that case it prints the error to stderr and writes null to stdout. With jq -e (exit-status), jq fails with exit code 1 whenever the result is null or false. That is the preferred option for required fields. To validate that an API response is valid JSON: jq -e . <<< "$body" >/dev/null 2>&1 || { echo "Invalid JSON response" >&2; exit 1; }.
#!/usr/bin/env bash
# jq_patterns.sh: jq patterns for API response processing
set -euo pipefail
# Safe field extraction with default value
jq_field() {
local json="$1" filter="$2" default="${3:-}"
local result
result="$(jq -r "$filter // empty" <<< "$json" 2>/dev/null)"
echo "${result:-$default}"
}
# Validate JSON and extract required field
extract_required() {
local json="$1" field="$2"
# Validate JSON structure first
if ! jq -e . <<< "$json" >/dev/null 2>&1; then
echo "[ERROR] Response is not valid JSON" >&2
return 1
fi
local value
value="$(jq -re ".$field" <<< "$json" 2>/dev/null)" || {
echo "[ERROR] Required field '$field' missing or null in response" >&2
return 1
}
echo "$value"
}
# Process paginated API with jq streaming
process_api_list() {
local endpoint="$1"
local response
response="$(curl -sS -H "Authorization: Bearer $API_TOKEN" "$endpoint")"
# Extract summary statistics
local total active_count names
total="$(jq 'length' <<< "$response")"
active_count="$(jq '[.[] | select(.status == "active")] | length' <<< "$response")"
names="$(jq -r '.[] | select(.status == "active") | .name' <<< "$response")"
echo "Total: $total, Active: $active_count"
echo "Active items:"
while IFS= read -r name; do
echo " - $name"
done <<< "$names"
}
# Build JSON payload safely (no string concatenation)
build_request_body() {
local name="$1" env="$2" version="$3"
jq -n \
--arg name "$name" \
--arg env "$env" \
--arg version "$version" \
--argjson timestamp "$(date +%s)" \
'{
name: $name,
environment: $env,
version: $version,
deployed_at: $timestamp
}'
}
payload="$(build_request_body "myapp" "production" "2.1.0")"
echo "Payload: $payload"
5. Authentication Methods in Bash API Scripts
The most common authentication methods in curl Bash automation are bearer tokens (OAuth2/JWT), API keys in a header, basic auth, and client certificates. Bearer tokens are passed as -H "Authorization: Bearer $TOKEN". API keys are passed, depending on the API, either as a header (-H "X-API-Key: $KEY") or as a query parameter (?api_key=$KEY, which is less secure since it ends up in logs). Basic auth uses -u "$USER:$PASS" or -H "Authorization: Basic $(echo -n "$USER:$PASS" | base64)".
Token refresh is an advanced pattern in curl Bash scripts: on the first 401 response, a new token is requested from the token endpoint, stored in a temporary file or variable, and the original request is retried. The OAuth2 client credentials flow can be implemented entirely in Bash: POST /oauth/token with grant_type=client_credentials, client_id, and client_secret, then use the returned access token for all subsequent requests. Never write tokens to log files: set -x in debug mode prints every variable value, which exposes tokens.
6. Retry Logic with Exponential Backoff
Networks are unreliable, APIs enforce rate limits, and servers run deployments. Robust curl Bash scripts therefore implement retry logic for transient failures. The basic principle: retry a request at most N times, wait between attempts with the wait time growing on each attempt (exponential backoff), and stop immediately on success. Non-transient errors (4xx other than 429/401) are not retried, since that would only generate useless traffic.
Exponential backoff with jitter is the industry-standard implementation: the wait time is not deterministic but includes a random component. This prevents every client from retrying at the exact same moment after a server restart (the thundering herd problem). In Bash: delay=$(( base_delay * 2**attempt + RANDOM % jitter )). The Retry-After header on 429 responses should be read and used: curl -sS -I "$url" | grep -i 'retry-after:' | awk '{print $2}'. This combination turns curl Bash automation into a first-class citizen in API-heavy environments.
#!/usr/bin/env bash
# retry_logic.sh: Exponential backoff retry for curl API calls
set -euo pipefail
# Constants
readonly MAX_ATTEMPTS=5
readonly BASE_DELAY=2
readonly MAX_DELAY=60
readonly JITTER=5
# Retry curl with exponential backoff and jitter
curl_with_retry() {
local attempt=1 delay http_code body response
while (( attempt <= MAX_ATTEMPTS )); do
response="$(curl -sS \
--connect-timeout 5 \
--max-time 30 \
-w "\n%{http_code}" \
"$@" 2>&1)" || true
http_code="$(tail -n1 <<< "$response")"
body="$(sed '$d' <<< "$response")"
# Success
if [[ "$http_code" =~ ^2 ]]; then
echo "$body"
return 0
fi
# Rate limited: respect Retry-After header if present
if [[ "$http_code" == "429" ]]; then
local retry_after
retry_after="$(curl -sS -I "${@: -1}" 2>/dev/null \
| grep -i 'retry-after:' | awk '{print $2}' | tr -d '\r')"
delay="${retry_after:-$BASE_DELAY}"
echo "[WARN] Rate limited (429). Waiting ${delay}s before retry $attempt/$MAX_ATTEMPTS" >&2
# Server errors: apply exponential backoff
elif [[ "$http_code" =~ ^5 ]]; then
delay=$(( BASE_DELAY * (2 ** (attempt - 1)) + RANDOM % JITTER ))
delay=$(( delay > MAX_DELAY ? MAX_DELAY : delay ))
echo "[WARN] Server error $http_code. Backoff ${delay}s (attempt $attempt/$MAX_ATTEMPTS)" >&2
# Client errors: do not retry
else
echo "[ERROR] Client error HTTP $http_code, not retrying" >&2
echo "$body" >&2
return 1
fi
sleep "$delay"
(( attempt++ ))
done
echo "[ERROR] All $MAX_ATTEMPTS attempts failed for: $*" >&2
return 1
}
# Usage example
result="$(curl_with_retry \
-H "Authorization: Bearer $API_TOKEN" \
-H "Accept: application/json" \
"https://api.example.com/v1/status")"
echo "API status: $(jq -r '.status' <<< "$result")"
7. Uploading and Downloading Files
Alongside JSON requests, file uploads and downloads are common tasks in curl Bash automation. For multipart/form-data uploads, use curl -F "file=@/path/to/file.zip" -F "name=release". The @ prefix before the file path tells curl to read the file content instead of sending the string literally. For binary downloads, curl -L -o output.bin --progress-bar URL is the cleanest form, with -L for redirects and -o for the destination file path.
For large downloads with resume support, curl in Bash offers the -C - flag (continue at): if the destination file already exists partially, the download resumes at the correct position. Integrity can be verified after the download with a SHA256 hash: sha256sum -c checksums.sha256. For API uploads with progress display and error handling, combine --progress-bar (visible in the terminal) or -# with -w for machine-readable byte counters.
8. Debugging and Logging curl Requests
Debugging curl Bash scripts starts with -v (verbose): it prints headers, the TLS handshake, and the redirect chain, providing far more detail than normal output. For machine-readable debug output, --trace-ascii /tmp/curl_trace.txt is better: it writes every byte of the communication to a file without mixing it into stdout. In production scripts, --write-out with a format string is the cleanest solution for metrics: -w "time_total:%{time_total} size_download:%{size_download}".
A common requirement in curl Bash production scripts is structured logging of every API call: method, URL, HTTP code, response time, and data volume. This can be implemented entirely in Bash with -w, without external tools. Sensitive data such as tokens must never be written to log files, so the script must explicitly avoid interpolating $API_TOKEN or similar variables into log output. set -x in debug mode should therefore be disabled in production scripts or have its output masked.
| curl flag | Function | When to use | Note |
|---|---|---|---|
-sS |
Silent output, show errors | Always in scripts | Required combination for automation |
-w "%{http_code}" |
Append HTTP code at the end | Fine-grained code evaluation | Better than --fail for precise control |
--connect-timeout |
Connection timeout | Always | Prevents blocking on dead servers |
--max-time |
Total request timeout | Always | Protects against hanging downloads |
-L |
Follow redirects | For HTTP to HTTPS redirects | Limit with --max-redirs |
9. Advanced Patterns: Parallel API Calls
For curl Bash scripts that need to make many independent API calls (for example, status checks across a hundred servers), parallelization is a decisive performance lever. The pattern: start curl in Bash in the background with &, collect the PIDs, and wait for all of them. Output is written to temp files to avoid mixing results. Use mktemp for each request and trap 'rm -f "${tmpfiles[@]}"' EXIT for cleanup.
curl itself also supports multiple simultaneous transfers with --parallel (curl 7.66+) and --parallel-max N. A single curl call can then fetch multiple URLs at once: curl --parallel --parallel-max 10 -o file1 url1 -o file2 url2. That is simpler than the manual parallelization pattern in Bash for straightforward batch download jobs, but it offers less control over per-request error handling. For production-grade curl Bash automation with differentiated error handling, the manual parallelization pattern remains the more flexible choice.
Mironsoft
Shell automation, API integration, and DevOps tooling
API integrations that stay stable even when things fail?
We build robust curl Bash automation with retry logic, structured logging, correct authentication handling, and complete response code handling, ready to drop straight into your CI/CD pipelines.
API Wrappers
Generic Bash wrappers for REST APIs with retry, auth, and JSON parsing
Monitoring Scripts
Health checks, SLA monitoring, and alert triggers via curl in Bash
CI/CD Integration
Deployment triggers and status checks via REST APIs in pipelines
10. Summary
Professional curl Bash automation rests on four pillars: the right flags (-sS --connect-timeout --max-time -w "%{http_code}"), reliable JSON processing with jq, secure authentication handling via environment variables, and retry logic with exponential backoff for transient failures. This combination turns curl in Bash into a full-fledged HTTP client that works reliably in CI/CD pipelines, monitoring scripts, and deployment automation.
The most important starting point for existing scripts: evaluate HTTP response codes explicitly instead of only checking the curl exit code. With -w "%{http_code}" and a function that handles the different code classes, a fragile one-liner becomes a resilient automation. jq with --arg for variable payloads and -e for required fields completes the picture.
curl in Bash: The Essentials at a Glance
Required Flags
-sS --connect-timeout 5 --max-time 30 -w "\n%{http_code}": silent output, timeouts, HTTP code evaluated separately.
JSON with jq
jq -e '.field' fails on null. --arg key "$var" for safe variable passing. Build payloads with jq -n.
Retry with Backoff
Only retry 5xx and 429. Exponential backoff with jitter: delay=$((BASE * 2**attempt + RANDOM % JITTER)).
Handle Auth Securely
Read tokens from environment variables. Never write them to logs. Disable or mask set -x in production.
11. FAQ: Testing and Automating HTTP APIs with curl in Bash
1Why does curl return exit code 0 for HTTP 404?
-w "%{http_code}" to read the code separately. --fail returns exit code 22 for 4xx/5xx but loses the body.2Safely passing JSON with Bash variables to curl?
jq -n --arg key "$var" '{key: $key}' instead of string interpolation. jq handles correct escaping for all special characters.3Storing a bearer token securely in a Bash script?
API_TOKEN=${API_TOKEN:?Required}. Configure as a protected variable in CI. Disable set -x during API calls.4--fail vs. -w "%{http_code}"?
--fail returns exit code 22 without the body. -w "%{http_code}" keeps the body and appends the code at the end. For differentiated handling, -w is the better choice.5Implementing exponential backoff in Bash?
delay=$((BASE * 2**(attempt-1) + RANDOM % JITTER)). Cap the maximum value. Only retry 5xx and 429, not 4xx.6Reading the Retry-After header from a 429 response?
curl -sS -I URL | grep -i 'retry-after:' | awk '{print $2}' | tr -d '\r'. Use the value directly for sleep.7Debugging a failing curl request?
curl -v shows headers and the TLS handshake. --trace-ascii /tmp/trace.txt writes every byte without mixing it into stdout.8Processing paginated API responses in Bash?
rel="next" link.9Checking whether an API response is valid JSON?
jq -e . <<< "$body" >/dev/null 2>&1: exit code 0 for valid JSON. Validate before every field extraction.10Uploading files with curl in Bash?
curl -F "file=@/path/to/file" URL. Binary: curl --data-binary @file -H "Content-Type: ..." URL. Resume: curl -C - -o output URL.