Computing FoundationsAPIs and the Web › Day 26

Hands-on lab — Day 26: Webhooks and Event-Driven APIs

Commands

Setup

cd labs/sections/computing-foundations/day-026-webhooks-and-event-driven-apis

Run

bash examples/webhook_demo.sh
bash starter/webhook_demo.sh

Test

bash tests/run_tests.sh

File tree

examples/webhook_demo.sh
expected-output/sample-run.txt
metadata.yml
README.md
requirements/README.md
security.md
starter/webhook_demo.sh
starter/webhook-worksheet.md
tests/run_tests.sh
troubleshooting.md

Lab README

Day 026 lab — Simulate a Webhook Delivery

Lesson

Purpose

Day 26's lesson explained the shift from pull to push — how a server calls your URL when an event happens. This lab makes it concrete without needing a public server: you play both sides of a webhook on your own machine. You compute the HMAC signature a sender attaches, deliver a sample payload to a free echo service so you can see exactly what a receiver receives, verify a genuine signature and reject a forged one, and watch a retry loop back off on a simulated failure.

Learning objectives

  • Compute an HMAC-SHA-256 signature over a raw payload with a shared secret.
  • Deliver a JSON payload as an HTTP POST and read what the receiver sees.
  • Verify a delivery by recomputing and comparing the signature, and detect a tampered payload.
  • Explain, from a working retry loop, why at-least-once delivery requires idempotent handling.

Prerequisites

  • The Day 26 lesson, and days 18–25 (HTTP, HTTPS/TLS, JSON, API authentication).
  • A terminal with curl and openssl (both preinstalled). Network access for the live delivery step only.

Supported operating systems

  • macOS and Linux — fully supported (curl and openssl preinstalled).
  • Windows — use WSL, or run the individual curl/openssl commands in a shell that provides them.

Hardware requirements

Any computer with a terminal. The live delivery step needs an internet connection; every other step is local.

Required software

curl and openssl only — preinstalled almost everywhere. See requirements/README.md.

Free and open-source options

Everything here is free: curl and openssl are open source, and the echo service (httpbin.org) is a free public endpoint. No accounts or API keys.

Installation

None. Change into this directory:

cd labs/sections/computing-foundations/day-026-webhooks-and-event-driven-apis

File structure

day-026-webhooks-and-event-driven-apis/
├── README.md                    ← you are here
├── metadata.yml                 ← machine-readable lab metadata
├── starter/
│   ├── webhook_demo.sh          ← YOUR working file (4 exercises)
│   └── webhook-worksheet.md     ← record your signature, echo, and notes
├── examples/
│   └── webhook_demo.sh          ← completed reference implementation
├── tests/
│   └── run_tests.sh             ← structure + local HMAC + (online) echo checks
├── expected-output/
│   └── sample-run.txt           ← real captured run (online)
├── requirements/README.md
├── troubleshooting.md
└── security.md

How to run

## 1. See the finished flow first (delivery step needs network)
bash examples/webhook_demo.sh

## 2. Complete the four exercises in the starter, then run it
bash starter/webhook_demo.sh

## 3. Check your work
bash tests/run_tests.sh

What the commands do

  • bash examples/webhook_demo.sh — runs the full simulation in three sections: (1) computes the HMAC signature a sender attaches; (2) POSTs the sample payload to httpbin.org/post and prints the body and signature header the receiver echoes back; (3) verifies a genuine signature (MATCH), rejects a tampered one (MISMATCH), and runs a retry loop that stops on a 200. It degrades gracefully offline.
  • bash starter/webhook_demo.sh — the same skeleton with four numbered exercises; each names the exact openssl/curl command to use.
  • bash tests/run_tests.sh — always runs structure and local HMAC checks (both scripts parse, the example exercises each step, the signature is a 64-hex string equal to the known value, a tampered body differs). When online it also confirms the echo endpoint returns your payload and signature; offline (or when httpbin is transiently down) that one check is skipped, never failed. Exits 0 on success.

Expected output

See expected-output/sample-run.txt for a real captured run. The signature is deterministic (sha256=cadd3bf8...e3c0); your trace ID and the exact echo formatting will differ.

Validation steps

  1. bash examples/webhook_demo.sh prints all three sections without errors.
  2. The signature is 64 hexadecimal characters and equals cadd3bf8973d72aec386c06d46c8803f68aa1f6936e9ee4a334adedace74e3c0.
  3. The echo section shows your exact payload body and X-Webhook-Signature.
  4. The verify section shows MATCH for the genuine payload and MISMATCH for the tampered one, and the retry loop stops on the 200.
  5. The tests pass.

Tests

bash tests/run_tests.sh

Expected final line online: 15 checks, 0 failure(s), 0 skip(s). Offline the live delivery check is skipped: 14 checks, 0 failure(s), 1 skip(s). Either way the command exits 0 on success, so it can run in CI.

Cleanup

Nothing to clean up — the scripts write no files and make one ordinary web request. Reset your edited starter with git checkout -- starter/webhook_demo.sh.

Troubleshooting

See troubleshooting.md — signature mismatches (trailing newline), openssl formatting, httpbin transient failures, the skip on the network check, and offline behavior.

Security notes

See security.md. Short version: the demo secret is fake — never use a real one in code; verifying signatures is what stops forged webhooks; always use HTTPS for a real receiver; never send real secrets to an echo service.

Extension exercises

  1. Add idempotency: record each delivery's ID and make a repeated ID acknowledge with 200 but act only once; send the same delivery twice and prove the action happened once.
  2. Add a timestamp inside the signed payload and reject deliveries older than five minutes, defending against replay of a captured message.
  3. Send a tampered payload with the original signature header to the echo service, recompute on receipt, and confirm your verify step would reject it.
  • Previous day: Day 25 — API Authentication: Keys, Tokens, and OAuth.
  • Next day: Day 27 — Rate Limits, Pagination, and Error Handling.

Expected output

sample-run.txt

Day 026 — Simulate a Webhook Delivery
Playing both sender and receiver locally; echo endpoint: https://httpbin.org/post

=== 1. Compute the webhook signature (what the SENDER does) ===
payload: {"event":"invoice.paid","id":"evt_28a7","data":{"amount":4200,"currency":"usd"}}
signature: sha256=cadd3bf8973d72aec386c06d46c8803f68aa1f6936e9ee4a334adedace74e3c0
(64 hex characters)

=== 2. Deliver the payload to an echo endpoint (what the RECEIVER sees) ===
the receiver received this body:
{"event":"invoice.paid","id":"evt_28a7","data":{"amount":4200,"currency":"usd"}}
and this signature header:
sha256=cadd3bf8973d72aec386c06d46c8803f68aa1f6936e9ee4a334adedace74e3c0

=== 3. Verify + retry ===
receiver recomputes signature over the body it got... MATCH -> would reply 200 OK
tampered payload recomputes to a different signature... MISMATCH -> reject (forged)
attempt 1: server returned 503 -> not 2xx, retrying after backoff...
attempt 2: server returned 503 -> not 2xx, retrying after backoff...
attempt 3: server returned 200 -> acknowledged, stop retrying

Done. You signed a payload, saw what a receiver receives, verified a
genuine signature, rejected a forged one, and watched retries back off.

Source files

examples/webhook_demo.sh (6226 bytes)
#!/usr/bin/env bash
# Day 026 lab — Simulate a Webhook Delivery (completed reference implementation).
#
# A real webhook needs a public server that another company's system can reach.
# You do not have one, so this script plays BOTH roles on your own machine:
#   1. SENDER  — compute the HMAC signature for a sample event payload.
#   2. RECEIVER — POST the payload to a free echo service (httpbin.org) so you
#      can see exactly what a webhook receiver would receive, then recompute
#      and compare the signature (accept a genuine one, reject a forged one).
#   3. RELIABILITY — run a retry loop that re-POSTs on a simulated non-2xx
#      status and stops once it gets a 2xx acknowledgement.
#
# Run from the lab directory:  bash examples/webhook_demo.sh
#
# The signature and verify steps are LOCAL and need no network. The delivery
# step reaches https://httpbin.org; offline, it degrades with a clear message
# and the script still exits 0.
set -u

# The sample event. printf '%s' (below) prints it with NO trailing newline, so
# the bytes we SIGN are exactly the bytes we SEND — this matters for HMAC.
PAYLOAD='{"event":"invoice.paid","id":"evt_28a7","data":{"amount":4200,"currency":"usd"}}'

# A FAKE secret, for the demo only. A real secret is a credential: keep it out
# of source code and logs (see security.md).
SECRET='whsec_demo_do_not_use_in_production'

HTTPBIN='https://httpbin.org'

rule() { printf '\n=== %s ===\n' "$1"; }

# Compute HMAC-SHA-256 of stdin with the given key; print just the 64-hex digest.
hmac_sha256() {
  local key="$1"
  openssl dgst -sha256 -hmac "$key" | sed 's/^.*= //'
}

# Return 0 if httpbin appears reachable, else 1.
have_network() {
  curl -s -o /dev/null --max-time 12 "${HTTPBIN}/get" 2>/dev/null
}

# ---------------------------------------------------------------------------
# 1. What the SENDER does: sign the raw payload.
# ---------------------------------------------------------------------------
show_sign() {
  rule "1. Compute the webhook signature (what the SENDER does)"
  local sig
  sig="$(printf '%s' "${PAYLOAD}" | hmac_sha256 "${SECRET}")"
  echo "payload: ${PAYLOAD}"
  echo "signature: sha256=${sig}"
  echo "(${#sig} hex characters)"
}

# ---------------------------------------------------------------------------
# 2. What the RECEIVER sees: deliver to an echo endpoint.
# ---------------------------------------------------------------------------
show_deliver() {
  rule "2. Deliver the payload to an echo endpoint (what the RECEIVER sees)"
  local sig response
  sig="$(printf '%s' "${PAYLOAD}" | hmac_sha256 "${SECRET}")"
  if ! have_network; then
    echo "OFFLINE: could not reach ${HTTPBIN}. Skipping the live delivery."
    echo "(The signature and verify steps above and below need no network.)"
    return
  fi
  # A real webhook is exactly this: a POST with a JSON body, a Content-Type,
  # an event-type header, and a signature header. httpbin echoes it all back,
  # so its response shows what a receiver would parse. -f fails on HTTP errors;
  # --retry with --retry-all-errors rides past httpbin's frequent transient
  # gateway hiccups and transfer resets (a real sender retries too).
  response="$(curl -sf --max-time 25 --retry 6 --retry-delay 2 --retry-all-errors \
    -X POST "${HTTPBIN}/post" \
    -H 'Content-Type: application/json' \
    -H 'X-Webhook-Event: invoice.paid' \
    -H "X-Webhook-Signature: sha256=${sig}" \
    -d "${PAYLOAD}" 2>/dev/null)" || response=""
  if [ -z "${response}" ]; then
    echo "(could not reach ${HTTPBIN} — transient error; try again in a moment)"
    return
  fi
  # Pull the echoed body and signature header back out of httpbin's JSON with
  # grep/sed only (no jq dependency).
  echo "the receiver received this body:"
  printf '%s\n' "${response}" | sed -n 's/.*"data": "\(.*\)", *$/\1/p' | sed 's/\\"/"/g' | head -n 1
  echo "and this signature header:"
  printf '%s\n' "${response}" | sed -n 's/.*"X-Webhook-Signature": "\(sha256=[0-9a-f]*\)".*/\1/p' | head -n 1
}

# ---------------------------------------------------------------------------
# 3. Verify (recompute + compare) and retry on non-2xx.
# ---------------------------------------------------------------------------
show_verify_and_retry() {
  rule "3. Verify + retry"
  local sent_sig recomputed tampered_payload tampered_sig
  sent_sig="$(printf '%s' "${PAYLOAD}" | hmac_sha256 "${SECRET}")"

  # The RECEIVER recomputes the signature over the body it got and compares.
  recomputed="$(printf '%s' "${PAYLOAD}" | hmac_sha256 "${SECRET}")"
  if [ "${sent_sig}" = "${recomputed}" ]; then
    echo "receiver recomputes signature over the body it got... MATCH -> would reply 200 OK"
  else
    echo "receiver recomputes signature over the body it got... MISMATCH -> reject"
  fi

  # A forged/tampered body recomputes to a DIFFERENT signature -> rejected.
  tampered_payload='{"event":"invoice.paid","id":"evt_28a7","data":{"amount":999999,"currency":"usd"}}'
  tampered_sig="$(printf '%s' "${tampered_payload}" | hmac_sha256 "${SECRET}")"
  if [ "${sent_sig}" = "${tampered_sig}" ]; then
    echo "tampered payload recomputes to the SAME signature... (should not happen)"
  else
    echo "tampered payload recomputes to a different signature... MISMATCH -> reject (forged)"
  fi

  # A sender retries until it gets a 2xx. We SIMULATE a receiver that fails
  # twice (503) then succeeds (200), to show at-least-once retrying + backoff.
  local -a statuses=(503 503 200)
  local attempt=1 code
  for code in "${statuses[@]}"; do
    if [ "${code}" -ge 200 ] && [ "${code}" -lt 300 ]; then
      echo "attempt ${attempt}: server returned ${code} -> acknowledged, stop retrying"
      break
    else
      echo "attempt ${attempt}: server returned ${code} -> not 2xx, retrying after backoff..."
    fi
    attempt=$((attempt + 1))
  done
}

main() {
  echo "Day 026 — Simulate a Webhook Delivery"
  echo "Playing both sender and receiver locally; echo endpoint: ${HTTPBIN}/post"
  show_sign
  show_deliver
  show_verify_and_retry
  echo
  echo "Done. You signed a payload, saw what a receiver receives, verified a"
  echo "genuine signature, rejected a forged one, and watched retries back off."
}

main "$@"
metadata.yml (593 bytes)
lesson_id: D026
day: 26
kind: api-example
languages: [bash]
setup_commands:
  - cd labs/sections/computing-foundations/day-026-webhooks-and-event-driven-apis
run_commands:
  - bash examples/webhook_demo.sh
  - bash starter/webhook_demo.sh
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'git checkout -- starter/webhook_demo.sh  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-12'
executed_on: 'macOS (curl 8.7.1, OpenSSL 3.6.3), online, bash tests/run_tests.sh → 15 checks, 0 failure(s), 0 skip(s)'
requirements/README.md (881 bytes)
# Dependencies — Day 026 lab

**`curl` and `openssl` only** — both preinstalled on macOS and mainstream
Linux (and available on Windows 10+ / WSL). No package installs, no accounts,
no API keys.

- `curl` — makes the sample webhook delivery (a `POST`) to a public echo
  service so you can see what a receiver receives.
- `openssl` — computes the HMAC-SHA-256 signature that authenticates a
  webhook, exactly as a real sender and receiver would.

The lab reaches one free public test service — `https://httpbin.org` — for the
live delivery step, so that step needs network access. Everything else (the
signature computation, the verify-and-compare, the retry simulation) is
**local** and works offline. With no network the scripts print a clear message
and the tests **skip** (never fail) the live delivery check, still verifying
the HMAC locally. No `sudo` required.
starter/webhook_demo.sh (3763 bytes)
#!/usr/bin/env bash
# Day 026 starter — Simulate a Webhook Delivery.
#
# Complete the FOUR exercises below. Each names the exact command to use.
# You will play both sides of a webhook: the SENDER that signs and delivers a
# payload, and the RECEIVER that verifies it. The completed reference is in
# ../examples/webhook_demo.sh — try to build it yourself first.
#
# Run from the lab directory:  bash starter/webhook_demo.sh
set -u

# The sample event. Use printf '%s' (NOT echo) so no trailing newline is added
# and the bytes you SIGN are exactly the bytes you SEND.
PAYLOAD='{"event":"invoice.paid","id":"evt_28a7","data":{"amount":4200,"currency":"usd"}}'

# A FAKE secret, for the demo only. Never hard-code a real secret (see security.md).
SECRET='whsec_demo_do_not_use_in_production'

HTTPBIN='https://httpbin.org'

rule() { printf '\n=== %s ===\n' "$1"; }

echo "Day 026 starter — Simulate a Webhook Delivery"

# ---------------------------------------------------------------------------
# Exercise 1: compute the signature (what the SENDER does).
# Replace the echo with:
#   printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET"
# It should print a 64-hex-character digest.
# ---------------------------------------------------------------------------
rule "1. Compute the signature"
echo "EXERCISE — your turn: HMAC-SHA-256 the PAYLOAD with the SECRET"

# ---------------------------------------------------------------------------
# Exercise 2: deliver the payload to the echo endpoint (what the RECEIVER sees).
# Replace the echo with a POST that carries the body and a signature header:
#   sig=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
#   curl -sf --max-time 25 --retry 6 --retry-delay 2 --retry-all-errors \
#     -X POST "$HTTPBIN/post" \
#     -H 'Content-Type: application/json' \
#     -H 'X-Webhook-Event: invoice.paid' \
#     -H "X-Webhook-Signature: sha256=$sig" \
#     -d "$PAYLOAD"
# httpbin echoes your body and headers back — that echo is what a receiver parses.
# ---------------------------------------------------------------------------
rule "2. Deliver to the echo endpoint"
echo "EXERCISE — your turn: POST the PAYLOAD to $HTTPBIN/post and read the echo"

# ---------------------------------------------------------------------------
# Exercise 3: verify by recomputing and comparing (what the RECEIVER does).
# Replace the echo with:
#   sent=$(printf '%s'   "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
#   recomputed=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
#   [ "$sent" = "$recomputed" ] && echo "MATCH -> accept" || echo "MISMATCH -> reject"
# Then repeat with a TAMPERED payload (change 4200 to 999999) and confirm it MISMATCHES.
# ---------------------------------------------------------------------------
rule "3. Verify the signature"
echo "EXERCISE — your turn: recompute the signature and compare; then try a tampered body"

# ---------------------------------------------------------------------------
# Exercise 4: simulate a retry loop (what the SENDER does on a non-2xx).
# Replace the echo with a loop over simulated statuses that stops on a 2xx:
#   for code in 503 503 200; do
#     if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
#       echo "returned $code -> acknowledged, stop retrying"; break
#     else
#       echo "returned $code -> not 2xx, retrying after backoff..."
#     fi
#   done
# ---------------------------------------------------------------------------
rule "4. Retry on non-2xx"
echo "EXERCISE — your turn: loop over 503 503 200 and stop retrying on the 200"

echo
echo "When all four are done, compare your output with ../examples/webhook_demo.sh"
starter/webhook-worksheet.md (1390 bytes)
# Webhook worksheet — Day 026

Run the commands (from the examples or your completed starter) and record what
YOUR run produced. The signature is deterministic, so it will match everyone
else's for the same payload and secret — but the echo response and trace ID
are unique to your run.

Sample event used:
`{"event":"invoice.paid","id":"evt_28a7","data":{"amount":4200,"currency":"usd"}}`
Secret used: `whsec_demo_do_not_use_in_production`

## 1. The signature your payload + secret produces

- Command: `printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET"`
- Signature (paste the full value): `sha256=____`
- How many characters is the hex digest? `____`  (should be 64)

## 2. What the echo endpoint reported back

Paste the body the receiver received:

```
____
```

Paste at least one header the receiver saw (e.g. `X-Webhook-Event` or
`X-Webhook-Signature`):

```
____
```

## 3. One reason webhooks retry

- In your own words, one concrete reason a webhook sender retries a delivery:

> _your answer here_

- The problem retrying creates for a receiver, and the property that solves it
  (name it):

> _your answer here_

## 4. Tampering detection

Change one character of the payload (for example `4200` → `999999`), re-run the
signature step, and answer:

- How much of the signature changed? `____`
- Why does that make tampering detectable?

> _your answer here_
tests/run_tests.sh (4511 bytes)
#!/usr/bin/env bash
# Tests for the Day 026 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Two kinds of checks:
#   * Structure + LOCAL checks always run and must pass: files present, both
#     scripts parse, the example exercises each core step, the starter names
#     its four exercises, and — with NO network — the HMAC signature computes
#     to the known 64-hex value (this is pure openssl, no internet).
#   * The NETWORK check runs only when httpbin.org is reachable: it POSTs the
#     payload and confirms the echo contains it. Offline, or when httpbin is
#     transiently down, that check is SKIPPED (never failed).
#
# The script exits 0 when no non-skipped check failed, online or offline.
set -u

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HTTPBIN='https://httpbin.org'
PAYLOAD='{"event":"invoice.paid","id":"evt_28a7","data":{"amount":4200,"currency":"usd"}}'
SECRET='whsec_demo_do_not_use_in_production'
EXPECTED_SIG='cadd3bf8973d72aec386c06d46c8803f68aa1f6936e9ee4a334adedace74e3c0'

failures=0
checks=0
skips=0

pass() { checks=$((checks + 1)); echo "  ok:   $1"; }
fail() { checks=$((checks + 1)); failures=$((failures + 1)); echo "  FAIL: $1"; }
skip() { skips=$((skips + 1)); echo "  skip: $1"; }

example="${lab_dir}/examples/webhook_demo.sh"
starter="${lab_dir}/starter/webhook_demo.sh"

echo "== Structure checks =="
[ -f "${example}" ] && pass "example script exists" || fail "example script missing"
[ -f "${starter}" ] && pass "starter script exists" || fail "starter script missing"
[ -f "${lab_dir}/starter/webhook-worksheet.md" ] && pass "worksheet exists" || fail "worksheet missing"

# The example must exercise each core step.
for needle in "openssl dgst -sha256 -hmac" "curl" "X-Webhook-Signature" "MISMATCH" "retrying"; do
  if grep -q -- "${needle}" "${example}"; then pass "example uses '${needle}'"; else fail "example missing '${needle}'"; fi
done

# The starter must name its four numbered exercises.
ex_count="$(grep -cE 'Exercise [1-4]' "${starter}" 2>/dev/null || true)"
if [ "${ex_count:-0}" -ge 4 ]; then pass "starter has 4 numbered exercises"; else fail "starter has 4 numbered exercises (found ${ex_count:-0})"; fi

# Both scripts must be valid bash.
if bash -n "${example}" 2>/dev/null; then pass "example has valid bash syntax"; else fail "example has a syntax error"; fi
if bash -n "${starter}" 2>/dev/null; then pass "starter has valid bash syntax"; else fail "starter has a syntax error"; fi

echo
echo "== Local HMAC checks (no network) =="
# Recompute the signature exactly as sender and receiver would.
sig="$(printf '%s' "${PAYLOAD}" | openssl dgst -sha256 -hmac "${SECRET}" | sed 's/^.*= //')"
# It must be exactly 64 lowercase hex characters.
if printf '%s' "${sig}" | grep -qE '^[0-9a-f]{64}$'; then
  pass "HMAC signature is a 64-hex-character string"
else
  fail "HMAC signature is not 64 hex characters (got '${sig}')"
fi
# It must equal the known, deterministic value for this payload+secret.
if [ "${sig}" = "${EXPECTED_SIG}" ]; then
  pass "HMAC signature matches the expected deterministic value"
else
  fail "HMAC signature != expected (got '${sig}')"
fi
# Tampering must change the signature (integrity property).
tampered="$(printf '%s' '{"event":"invoice.paid","id":"evt_28a7","data":{"amount":999999,"currency":"usd"}}' | openssl dgst -sha256 -hmac "${SECRET}" | sed 's/^.*= //')"
if [ "${sig}" != "${tampered}" ]; then
  pass "a tampered payload produces a different signature (rejected)"
else
  fail "tampered payload should not match the original signature"
fi

echo
echo "== Network check (httpbin echo) =="
if ! curl -s -o /dev/null --max-time 12 "${HTTPBIN}/get" 2>/dev/null; then
  skip "no network access — skipping the live delivery echo check (expected offline)"
else
  echoed="$(curl -sf --max-time 25 --retry 6 --retry-delay 2 --retry-all-errors \
    -X POST "${HTTPBIN}/post" \
    -H 'Content-Type: application/json' \
    -H "X-Webhook-Signature: sha256=${sig}" \
    -d "${PAYLOAD}" 2>/dev/null)" || echoed=""
  if [ -z "${echoed}" ]; then
    skip "delivery check — httpbin transiently unavailable; retry later"
  elif printf '%s' "${echoed}" | grep -q 'invoice.paid' \
    && printf '%s' "${echoed}" | grep -q "${sig}"; then
    pass "the echo endpoint returned our payload and signature header"
  else
    fail "the echo endpoint should echo our payload and signature"
  fi
fi

echo
echo "${checks} checks, ${failures} failure(s), ${skips} skip(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 026 lab

My signature does not match cadd3bf8...e3c0

Almost always a trailing newline. Use printf '%s' "$PAYLOAD" (not echo, which appends \n) so the bytes you sign are exactly the payload bytes. Also confirm you used the identical payload string and the identical secret — a single changed byte in either flips the whole signature.

openssl: command not found

Install it (brew install openssl on macOS, sudo apt install openssl on Debian/Ubuntu); it is preinstalled on nearly all systems. The signature step needs it.

openssl prints HMAC-SHA256(stdin)= <hex> instead of just the hex

Different openssl builds format the label differently. The scripts strip everything up to = with sed 's/^.*= //', which handles all the common formats. If you are running the command by hand, pipe it through that sed to keep only the digest.

The delivery step prints "could not reach httpbin"

httpbin.org is a shared free service and frequently returns gateway errors or resets connections under load. The scripts already retry with backoff (--retry ... --retry-all-errors). If it still fails, wait a minute and re-run — the signature and verify steps do not need the network and always work. A failed delivery is not your bug.

The test reports a skip on the network check

That is expected and fine: offline (or when httpbin is transiently down) the live delivery check is skipped, never failed. The local HMAC checks still run and must pass. 0 failure(s) is a pass whether there are skips or not.

The retry loop always shows 503, 503, 200

That is intentional — the loop uses a fixed, simulated sequence of statuses so the demo is deterministic and needs no flaky server to fail on purpose. It shows the shape of at-least-once retrying: keep trying on non-2xx, stop on a 2xx acknowledgement.

Windows: bash is not recognized

Use WSL (wsl --install, then open Ubuntu and follow the same steps), or run the individual openssl and curl commands in a shell that has them.

Security notes

Security notes — Day 026 lab

  • Use a fake secret. This lab's secret, whsec_demo_do_not_use_in_production, is deliberately fake and public. A real webhook secret is a credential exactly like an API key: keep it out of source code, out of logs, and out of shared terminals. Never commit a real secret to a repository.
  • Verifying signatures is what prevents forged webhooks. A receiver's URL is public — anyone who learns it can POST to it, including an attacker forging a "payment succeeded" event. The security is entirely in recomputing the HMAC over the body and comparing it. A receiver that skips verification trusts anyone. In production, compare signatures with a constant-time check and reject deliveries with a stale timestamp to blunt replay attacks.
  • Always use HTTPS for a real receiver. Serve the endpoint over HTTPS (Day 19's TLS) so the payload and signature cannot be read or altered on the wire. This lab only simulates a receiver, so it hosts nothing public.
  • Never send real secrets to an echo service. httpbin.org echoes back whatever you send it. The lab sends only a non-sensitive sample payload and a fake signature. Do not post real tokens, keys, or personal data to any echo endpoint.
  • What the scripts do: compute a hash locally and make one ordinary POST to a public test service. No sudo, no files written outside the lab, no data of yours sent beyond the sample payload.