service-status.sh already tells process, port and application apart, but it stops at the door: it knows /salud returns a 200, not what it says. The veloz-api answers JSON —database state, queued shipments, deployed version, metrics—, and that is where the toolkit stops reading files somebody left on disk and starts integrating with the rest of the system. This lesson covers curl in depth for talking to APIs and jq for handling JSON properly: extracting it, filtering it, turning it into columns and also building it safely, because in the end daily-report.sh will publish its daily summary as JSON.

Contents

  1. curl in scripts: the -sSf trio
  2. Methods, headers and bodies
  3. Files, redirects and timeouts
  4. Credentials without leaving a trace
  5. HTTP codes: what a script must tell apart
  6. Body and status code in a single call
  7. Why you do NOT parse JSON with grep or sed
  8. jq: the filter model
  9. Selecting, filtering and aggregating
  10. Controlling the output: -r, -c, -e and @tsv
  11. Building JSON safely
  12. Reading JSON in Bash, and JSON as configuration
  13. Application: the toolkit talks to the API and publishes JSON

  1. curl in scripts: the -sSf trio

By default curl is designed for a person at a terminal: it writes a progress bar and, if the server answers an error, it prints the error page and returns 0. In a script that is exactly the opposite of what you want. Three options fix it:

Option Effect Why
-s Silent: no progress bar, no messages The bar dirties the output and the logs
-S But it does show curl's errors With a bare -s, a network failure would be mute
-f Fails (code 22) on HTTP 4xx and 5xx responses Without this, $? is 0 even if the server answers 500
curl -sSf http://localhost:8080/salud || veloz_die 1 "the API is not responding correctly"

Memorize -sSf as a block: silent, but complaining, and genuinely failing. It is the most widespread mistake in scripts that talk to APIs, and it produces the worst possible kind of failure: a silent one that makes you believe everything is fine.

  1. Methods, headers and bodies

For REST APIs you need four more options:

curl -sSf -X POST -H 'Content-Type: application/json' -H "Authorization: Bearer $VELOZ_TOKEN" \
     -d '{"city":"Madrid","status":"issue"}' http://localhost:8080/envios

-X sets the method (GET, POST, PUT, DELETE); it is not needed with -d, which already implies POST. -H adds a header and is repeated as many times as necessary. -d sends the body —if it starts with @, it reads it from a file: -d @body.json—. For parameters with special characters there is --data-urlencode, which encodes the value: curl -sSfG --data-urlencode "ciudad=Palma de Mallorca" http://localhost:8080/envios. That -G turns the data into URL parameters instead of a body, so the result is /envios?ciudad=Palma%20de%20Mallorca. Writing that URL by hand with a Bash variable is a bug waiting to happen: one space, one & or one accent in the value and the request changes meaning.

  1. Files, redirects and timeouts

Option What it does
-o file Saves the body in that file (-o /dev/null to throw it away)
-O Saves it with the name it has in the URL
-L Follows 301/302 redirects (it does not by default)
--connect-timeout N Maximum to establish the connection
--max-time N Maximum for the whole operation

The two timeouts are different and you have to set both. --connect-timeout 5 cuts off quickly when the machine is not there; --max-time 30 protects you from the server that accepts the connection and then thinks about it indefinitely. A curl without --max-time in an automated script is a time bomb: the day the service gets stuck, your cron job will hang forever and block the following ones (07-01).

  1. Credentials without leaving a trace

-u user:password does basic authentication, but written like that the password shows up in ps, in the history (02-06) and in any log of the command. The two correct ways:

curl -sSf --netrc-file ~/.netrc https://api.veloz.example/envios     # credentials in a 600 file
curl -sSf -H "Authorization: Bearer $VELOZ_TOKEN" http://localhost:8080/metricas

--netrc-file reads machine, user and password from a file with 600 permissions, just like veloz-ops.conf (05-06). The second option uses an environment variable loaded from that configuration file: it does not stay in the history, although it is visible in /proc/<pid>/environ to the user themselves and to root. What must never be done is to embed the credential in the URL, as already seen in 06-04.

  1. HTTP codes: what a script must tell apart

Family Meaning What the script should do
2xx Correct Carry on
3xx Redirect Follow it with -L, or treat it as incorrect configuration
4xx You got it wrong (401, 403, 404, 422) Fail and do not retry: retrying will not fix it
5xx The server failed (500, 502, 503) Retry with backoff (06-04); it may be temporary
No response Network, DNS, timeout Tell it apart from a 5xx in the message

That distinction between 4xx and 5xx is what makes an integration script useful: retrying a 404 is a waste of time, and not retrying a 503 means writing off a service that was restarting. And remember the starting point: without -f, curl returns 0 with any code, so without it you do not even find out.

  1. Body and status code in a single call

-f tells you there was an error, but it discards the body, which usually contains the explanation. To have both without making two requests, you ask for the code with -w at the end and separate it afterward:

response=$(curl -sS -w '\n%{http_code}' --max-time 10 "http://localhost:8080/envios?ciudad=Madrid")
code="${response##*$'\n'}"        # last line: the code
body="${response%$'\n'*}"         # everything before: the body
case "$code" in
    2??) veloz_log_info "query OK" ;;
    4??) veloz_die 1 "bad request ($code): $body" ;;
    5??) veloz_log_error "server error ($code), retrying"; return 1 ;;
    *)   veloz_die 1 "no response from the API" ;;
esac

Three things from previous modules come together here: -w '\n%{http_code}' appends the code on a new line at the end, the expansions ${var##*} and ${var%*} from 04-04 separate it from the body without spawning processes, and the case with globs from 04-05 classifies by family with 2??. Notice that -f is not used here: we want the error body, so the case makes the decision.

  1. Why you do NOT parse JSON with grep or sed

The temptation is enormous and the result always ends badly. To pull the status out of /salud, what everybody writes the first time is curl -s $API/salud | grep -o '"status":"[^"]*"' | cut -d'"' -f4. It works until it stops working, and the reasons are not far-fetched: the JSON may come compact or with line breaks (your regex depends on the formatting, which the server can change without warning); the key order is not guaranteed; a nested object with another status key may show up and your grep will take the first one it finds; a value may contain escaped quotes ("message":"error \"severe\"") that destroy [^"]*; and null, numbers and booleans carry no quotes, so the pattern does not even see them. In short: JSON is not a line-based format, and line-based tools cannot understand it reliably. What you need is a parser, and that is jq.

  1. jq: the filter model

jq is a language of filters: it takes a JSON document, applies an expression to it and emits JSON. The central idea is that every filter transforms an input into zero, one or several outputs, and they are chained with | like shell pipes.

Suppose /salud returns {"status":"ok","version":"2.4.1","db":{"connected":true,"latency_ms":12},"queue":[3,7,2]}:

Filter Result What it does
. The whole document, formatted Identity; useful for reading it
.status "ok" Accesses a key
.db.latency_ms 12 Nested path
.queue[0] 3 Array index
.queue[1:] [7,2] Slice
.queue[] 3, 7, 2 Iterates: emits three separate outputs
.queue | length 3 Length of an array, object or string
keys ["db","queue","status","version"] Sorted keys
has("status") true Does that key exist?
.missing null A non-existent key is not an error
.status // "unknown" "ok" Default value if it is null or false
.db.x? (nothing) ? suppresses the error if the type does not fit

The last two rows are the ones that prevent half the scares: // gives a default value when the API omits a field, and ? keeps a type error from aborting the whole filter. The difference between .queue (an array) and .queue[] (three outputs) is the concept to internalize: most interesting filters work on streams of values, not on a single one.

  1. Selecting, filtering and aggregating

Over a response from /envios?ciudad=Madrid, which returns an array of objects with id, status and amount:

curl -sSf "$API/envios?ciudad=Madrid" | jq '.[] | select(.status == "issue")'
curl -sSf "$API/envios?ciudad=Madrid" | jq '[.[] | .amount] | add'
Filter What it does
select(cond) Lets through only the elements meeting the condition
map(f) Applies f to each element of an array ([.[] | f] abbreviated)
add Sums the elements of an array (or concatenates them)
length Number of elements, keys or characters
sort_by(.field) Sorts an array by that field
min / max / min_by(.f) Extremes
group_by(.field) Groups into an array of arrays (requires sorting by the same field)
to_entries Turns {"a":1} into [{"key":"a","value":1}] so objects can be iterated

The brackets in [.[] | .amount] matter: .[] | .amount emits three loose values, and add needs an array, so they have to be collected. Confusing "stream of values" with "array" is mistake number one with jq, and it is always fixed with [ ] or with map(). You will notice that group_by + map is the same aggregation you were doing with associative arrays in awk (06-01), only over JSON.

  1. Controlling the output: -r, -c, -e and @tsv

By default jq emits JSON, so a string comes out quoted: "ok", not ok. When you assign it to a Bash variable, those quotes travel with it and ruin any later comparison.

Option Effect
-r Raw: emits strings without quotes
-c Compact: each result on a single line
-e The exit code reflects the result: 1 if it was null or false
-n Reads no input; builds from scratch (section 11)
status=$(curl -sSf "$API/salud" | jq -r '.status')     # "ok" -> ok
[[ "$status" == ok ]] || veloz_log_error "API in status $status"
curl -sSf "$API/salud" | jq -e '.db.connected' >/dev/null || veloz_log_error "DB disconnected"

-e turns a JSON query into a shell condition, with no intermediate variables. And to get back into awk territory, @tsv and @csv turn arrays into columns:

curl -sSf "$API/envios?ciudad=Madrid" | jq -r '.[] | [.id, .status, .amount] | @tsv' |
    awk -F'\t' '{ s[$2] += $3 } END { for (e in s) printf "%-12s %8.2f\n", e, s[e] }'

That chaining is the bridge between the two worlds: jq understands the structure and flattens it into columns; awk aggregates. @csv quotes the fields according to CSV rules, and both require -r so that everything does not come out as an escaped JSON string.

  1. Building JSON safely

Generating JSON by hand —printf '{"city":"%s","note":"%s"}\n' "$city" "$note"— works until the first value with a quote, a backslash or a line break, and then it produces invalid JSON, or worse, valid JSON with altered content. The correct way is jq -n, which builds from scratch, with --arg for strings and --argjson for values that are already JSON (numbers, booleans, objects):

jq -n --arg date "$TODAY" --arg host "$(hostname)" \
      --argjson total "$total" --argjson rate "$rate" \
      '{date: $date, host: $host, total: $total, delivery_rate: $rate}'
# -> {"date":"2026-08-03","host":"srv-veloz-01","total":1001,"delivery_rate":87.3}

jq takes care of the escaping: a quote inside $note comes out as \", a line break as \n and accented characters are encoded correctly. The distinction between --arg and --argjson is the usual trap: --arg total 1001 produces "1001" (a string) and --argjson total 1001 produces 1001 (a number). If the value can come in empty, --argjson will fail —jq does not accept invalid JSON—, so a ${total:-0} is a good idea. And as with awk -v in 06-01, the principle is the same: Bash values go in as parameters, never interpolated inside the filter, because interpolating them is code injection.

  1. Reading JSON in Bash, and JSON as configuration

To walk a response in Bash, the combination is jq -r '.[] | @tsv' with the loop from 04-01:

while IFS=$'\t' read -r id status amount; do
    [[ "$status" == issue ]] && veloz_log_error "shipment $id with issue ($amount EUR)"
done < <(curl -sSf "$API/envios?ciudad=Madrid" | jq -r '.[] | [.id, .status, .amount] | @tsv')

@tsv is preferable to splitting on spaces because the values may contain them, and it also escapes any tabs and line breaks that were inside a field. The process substitution < <( ) from 05-05 keeps the loop in the current shell. When what you need is a Bash array with a single column, mapfile -t ids < <(jq -r '.[].id' <<< "$json") (04-03) is more direct.

JSON also works as a configuration format, and jq reads it with the same rules: jq -r '.thresholds.disk // 85' etc/veloz.json returns the value or the default if it is missing. Compared to the key=value of veloz-ops.conf (05-06), it wins on nested structure and types, and loses in that it cannot be loaded with source and takes no comments. For YAML —the format of CI and Kubernetes files— there is yq, which replicates jq's syntax over YAML and even converts between the two with yq -o=json. If you already know jq, you know yq.

  1. Application: the toolkit talks to the API and publishes JSON

First, service-status.sh queries the API for real and tells an outage apart from an application error, which is the difference between restarting the service and alerting the development team:

check_api_json() {
    local api="http://localhost:8080" body code output
    output=$(curl -sS -w '\n%{http_code}' --connect-timeout 3 --max-time 10 "$api/salud") || {
        veloz_log_error "veloz-api: no response (network, DNS or service down)"; return 1; }
    code="${output##*$'\n'}"; body="${output%$'\n'*}"
    [[ "$code" == 2?? ]] || {
        veloz_log_error "veloz-api: HTTP $code — $(jq -r '.message // "no detail"' <<< "$body")"
        return 1; }
    jq -e '.status == "ok"' <<< "$body" >/dev/null ||
        veloz_log_error "veloz-api: alive but degraded ($(jq -r '.status' <<< "$body"))"
    veloz_log_info "veloz-api $(jq -r '.version' <<< "$body") — DB $(jq -r 'if .db.connected then "ok" else "KO" end' <<< "$body")"
}

The three outcomes are deliberately different: no response (the curl fails), a response with an HTTP error (.message is extracted from the body for the alert) and a correct response but with a degraded status. jq -e uses the JSON as a condition and jq's if ... then ... else ... end formats the boolean for the message. Note the <<< "$body", the here-string from 05-05: it avoids rereading the network response every time.

And daily-report.sh closes the circle by publishing its summary as JSON, built with jq -n:

publish_summary() {
    local dest="$BASE_DIR/logs/summary-$(date +%F).json" tmp
    tmp=$(mktemp) && trap 'rm -f "$tmp"' RETURN
    jq -n --arg date "$(date +%F)" --arg host "$(hostname -f)" \
          --argjson total "${total:-0}" --argjson rate "${rate:-0}" \
          '{generated: (now | todate), date: $date, host: $host,
            summary: {shipments: $total, delivery_rate: $rate}}' > "$tmp" && mv "$tmp" "$dest"
    veloz_log_info "summary published to $dest"
}

The mktemp with trap from 05-01 and the final mv guarantee that nobody ever reads a half-written file: either the complete summary exists or the previous day's does. now | todate generates the timestamp in UTC ISO-8601 without calling date. And now the report is no longer just text to read: it is a piece of data that another program —a dashboard, an alert, project 09-05— can consume without parsing anything again.

Common Mistakes and Tips

  • curl without -f (or without checking the code). It returns 0 with a 500 and the script carries on as if nothing happened. Use -sSf, or -w '%{http_code}' and a case.
  • curl without --max-time. A stuck service hangs the cron job indefinitely.
  • Parsing JSON with grep/sed. It fails with compact formatting, key order, nesting and escaped quotes. Use jq.
  • Forgetting -r. status=$(jq '.status') stores "ok" with quotes and [[ $status == ok ]] fails.
  • Confusing a stream with an array. .[] | .amount emits loose values; add needs [.[] | .amount].
  • --arg for numbers. It produces "1001" instead of 1001; for numbers and booleans, --argjson.
  • Interpolating Bash variables inside the jq filter. Same injection risk as in awk: use --arg.
  • Retrying a 4xx. It is not going to fix itself. Retry the 5xx and the network failures, with backoff (06-04).
  • Tip: build your filters step by step (jq '.', then jq '.field'…) against a sample response saved in a file: it is faster than calling the API, it works offline and jq is much easier to debug in increments than in one go.

Exercises

Exercise 1. Write a function veloz_api_get for lib/common.sh that does a GET to an API path, returns the body on standard output, tells network / 4xx / 5xx apart with different messages and different exit codes, and cannot hang.

Exercise 2. With /metricas returning {"requests":48210,"errors":37,"latency_p95_ms":210,"queued_shipments":14}, write a check that warns if the error rate exceeds 0.1% or if the p95 latency goes over 500 ms, treating missing fields as 0.

Exercise 3. Turn the output of /envios?ciudad=Madrid (an array of objects with id, courier, status, amount) into a summary per courier with the number of shipments and the total amount, sorted from highest to lowest, using only jq.

Solutions

Solution 1.

# veloz_api_get — GET to the API. Usage: veloz_api_get <path>. Returns the body on stdout.
veloz_api_get() {
    local path="${1:?path missing}" base="${VELOZ_API:-http://localhost:8080}" output code
    output=$(curl -sS -w '\n%{http_code}' --connect-timeout 3 --max-time 15 "$base$path") ||
        { veloz_log_error "API: no response on $path"; return 69; }
    code="${output##*$'\n'}"
    case "$code" in
        2??) printf '%s\n' "${output%$'\n'*}" ;;
        4??) veloz_log_error "API: bad request ($code) on $path"; return 64 ;;
        *)   veloz_log_error "API: server error ($code) on $path"; return 75 ;;
    esac
}

The three codes are deliberate and follow the table in 05-03: 69 (EX_UNAVAILABLE) when there is no response, 64 (EX_USAGE) for a 4xx —the mistake is ours— and 75 (EX_TEMPFAIL) for a 5xx, which is precisely the code that tells the caller "this is temporary, you can retry". The body comes out clean on stdout and the diagnostics on stderr (02-04), so that data=$(veloz_api_get /salud) captures only what matters.

Solution 2.

metrics=$(veloz_api_get /metricas) || exit $?
read -r requests errors p95 < <(jq -r '[.requests // 0, .errors // 0,
    .latency_p95_ms // 0] | @tsv' <<< "$metrics")
awk -v e="$errors" -v p="$requests" 'BEGIN { exit !(p > 0 && 100*e/p > 0.1) }' &&
    veloz_log_error "error rate above 0.1% ($errors out of $requests)"
(( p95 > 500 )) && veloz_log_error "p95 latency of ${p95}ms above the threshold"

The // 0 on each field keeps an absent metric from turning into null and breaking the arithmetic. The three values are extracted in a single invocation of jq with @tsv and a read, instead of calling it three times. The error rate is decimal, so the comparison goes in awk with the exit !(...) idiom from 06-03; the latency is an integer and (( )) is enough for it.

Solution 3.

veloz_api_get "/envios?ciudad=Madrid" |
    jq -r 'group_by(.courier)
           | map({courier: .[0].courier, shipments: length, total: (map(.amount) | add)})
           | sort_by(-.total)
           | .[] | [.courier, .shipments, (.total | tostring)] | @tsv' |
    column -t

group_by produces an array of arrays —one per courier—, and map turns it into objects: .[0].courier takes the name from any element of the group, length counts and map(.amount) | add sums. sort_by(-.total) sorts descending by negating the value, cleaner than sorting and then reverse. At the end, @tsv flattens it into columns and column -t (02-02) aligns them. It is exactly the aggregation from solution 1 of 06-01, but starting from JSON instead of CSV.

Conclusion

Talking to an API from a script is two tools and a handful of rules. From curl: -sSf as an indivisible block —silent, complaining about network failures and genuinely failing on an HTTP error, because without -f it returns 0 with a 500—; -X, -H, -d and --data-urlencode to build the request; -L, -o/-O for the body; --connect-timeout and --max-time always, or the day the service gets stuck you will hang the cron job; credentials via --netrc or a header from a 600 file, never in the URL; and -w '\n%{http_code}' to keep body and code in a single call and classify with a case that tells 4xx (do not retry) from 5xx (retry with backoff). From jq: a language of filters chained with | where .field, .a.b, .[], select, map, group_by, add, sort_by, length, to_entries, // and ? cover almost everything; -r to get strings without quotes, -e to use JSON as a condition, @tsv/@csv to get back into awk territory; and jq -n --arg/--argjson to build JSON with the escaping already solved, passing Bash values as parameters and never interpolated. And the rule that covers it all: JSON is not a line-based format, so grep and sed are no use for reading it.

With this Module 6 closes and the Veloz Envíos toolkit changes in nature. daily-report.sh aggregates in a single pass with awk and publishes its summary in JSON; service-status.sh gathers the system context, checks network and port, interrogates the veloz-api and tells an outage apart from an application error. It is no longer a file reader: it is a piece integrated with the rest of the system. But notice what every run in this module has in common: you are the one who typed the command. The daily report only exists if somebody remembers to launch it, and a status check that runs when you already suspect something is wrong arrives late by definition. In Module 7 that ends: cron so tasks run by themselves (07-01), the design of truly unattended jobs (07-02), automatic backups (07-03), continuous monitoring and logging (07-04), systemd services and timers (07-05) and remote automation with ssh (07-06). The toolkit stops waiting for your orders.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved