- Quickstarts
- Quickstart: curl
Quickstarts
Quickstart: curl
Nine calls with curl and jq, from a clean shell to a delivery attempt you can read.
Nine calls with curl and jq, from a clean shell to a delivery
attempt you can read.
Needs curl 8.3.0 or newer — that is the release
that added --variable and --expand-header, which is how the
credential reaches the request without ever appearing in the command
line. curl --version will tell you. If yours is older, upgrade it or
use the JavaScript quickstart instead.
Read Before you begin first: it lists the three prerequisites this cannot get for you and the variables it reads.
Run it
Save the whole thing as first-value.sh, then:
export GREATWAIT_API_URL=https://api.greatwait.dev
read -rs GREATWAIT_API_KEY && export GREATWAIT_API_KEY
export GREATWAIT_RECEIVER_URL=https://receiver.example.com/greatwait
bash first-value.sh
read -rs keeps the credential out of your shell history, which is the
one place a pasted export GREATWAIT_API_KEY=... would leave it.
What each step does
1. Check which service and environment you reached
GET /__greatwait/version — meta.version
Confirms the origin you are pointed at is a Greatwait API and says which environment it serves. It needs no credential, so it separates ‘wrong URL’ from ‘wrong key’.
What you should see. The service name and the environment you meant to work in. If the environment is not the one you expect, stop and fix GREATWAIT_API_URL before creating anything.
If it does not work.
| When | Next move |
|---|---|
unexpected-answer | Something answered, but not this service. Check GREATWAIT_API_URL — a 404 here comes from a proxy in front of the API rather than from the API, so it carries no problem document to read. |
no-response | The origin did not answer. Check the hostname and that you can reach it from this network before going further. |
2. Check the credential
GET /v1/schedules — schedules.list
Proves the credential works and is bound to the project you think it is, before you create anything with it. A credential names exactly one project, and this is the cheapest call that says so.
What you should see. An empty page on a new project, or your existing schedules. Either way the credential is accepted and carries the scopes this journey needs.
If it does not work.
| When | Next move |
|---|---|
authentication_required | No credential reached the service. Export GREATWAIT_API_KEY in this shell and try again. |
credential_revoked | The credential was revoked. Create a new one in the dashboard; a revoked credential never comes back. |
credential_expired | The credential expired. Rotate it in the dashboard and export the new secret. |
scope_missing | The credential is missing schedules:read. Create one with the scopes this journey needs rather than widening this one. |
3. Create the destination
POST /v1/endpoints — endpoints.create
Registers the address every occurrence of this schedule is delivered to, and mints the per-endpoint signing key your receiver verifies callbacks with.
What you should see. An endpoint identifier, and a signing secret returned by this call and by no other. Store the secret now: reading the endpoint back later will not show it again.
If it does not work.
| When | Next move |
|---|---|
invalid_request | The URL was refused. It must be HTTPS on port 443, carry no credentials or fragment, and resolve to a public address — a localhost or private-range host cannot be reached from the service. |
plan_limit_reached | This project is at its destination ceiling. Disable a destination you no longer deliver to; disabled ones still count, because they stay readable for audit. |
state_conflict | An active destination already delivers to this address in this project — usually one a previous run of this journey created. Reuse that destination, disable it first, or give the new one a name of its own. |
4. Create the one-shot
POST /v1/schedules — schedules.create
Creates the one-shot. The delay is converted to an absolute instant when the request is accepted, so the schedule reads back the same afterwards however it was expressed.
What you should see. A schedule whose recurrence kind is at, and its first occurrence in state planned with the instant your delay resolved to.
If it does not work.
| When | Next move |
|---|---|
not_found | The endpoint identifier is not one this credential’s project can deliver to — it belongs to another project, or there is no such endpoint. Create a destination in this project, or check the identifier. |
state_conflict | The endpoint is disabled. Enable it with endpoints.enable and the schedule is accepted as sent, or point the schedule at another destination. |
idempotency_key_reused | The same idempotency key was used for a different request. Use a new key, or send the byte-identical body the key was first used with. |
invalid_request | Give exactly one of at, delaySeconds, everySeconds and cron, and make sure the endpoint identifier is one this credential’s project owns. |
plan_limit_reached | This organization is at a plan ceiling — live schedules, distinct partition keys, or payload size. Nothing was created; reduce the payload or archive a schedule and retry. |
service_unavailable | The service could not accept the schedule right now. This is safe to retry with the same idempotency key, which is what the key is for. |
5. Retry it safely
POST /v1/schedules — schedules.create
Repeats the previous call byte for byte, with the same key. This is the retry a network failure would have made for you, and it is the reason a create is safe to retry at all.
What you should see. The identical schedule identifier and an Idempotency-Replayed: true response header. Nothing was created the second time, and no second callback will be delivered.
If it does not work.
| When | Next move |
|---|---|
idempotency_key_reused | The body differed from the first call. A replay must be byte-identical; a changed body with a used key is a conflict rather than a second write. |
6. Read the schedule back
GET /v1/schedules/{scheduleId} — schedules.read
Reads the schedule back from the partition that owns it, which is also where the version you need for a conditional write comes from.
What you should see. The same schedule, with a strong ETag in the response. Send that value back in If-Match to make a later pause, replace or cancel conditional on nothing having changed in between.
If it does not work.
| When | Next move |
|---|---|
not_found | A schedule in another organization, project or environment answers not_found rather than a refusal. Check that this credential belongs to the project that created it. |
7. Wait for the occurrence to settle
GET /v1/schedules/{scheduleId}/occurrences/{occurrenceId} — occurrences.read
Waits for the occurrence to settle. Delivery is at-least-once and a failing attempt is retried, so the state moves through due and dispatching before it settles.
What you should see. The occurrence reaching succeeded. terminal_failed is also a settled answer, and the attempt in the next step says why.
Bounded wait. Poll until the occurrence reaches a terminal state, or until the deadline passes. It polls every 5 seconds and gives up after
If it does not work.
| When | Next move |
|---|---|
not-settled | The occurrence never settled inside the deadline. It is not lost: read it again later, and read its attempts to see how far delivery got. |
8. Read the delivery attempt
GET /v1/schedules/{scheduleId}/occurrences/{occurrenceId}/attempts — attempts.list
Reads the immutable delivery history. This is the point the journey exists for — the customer-readable outcome of a real call to their own address.
What you should see. At least one attempt. state: succeeded with the status your receiver returned is first value: the callback was made, your receiver answered, and the outcome is recorded.
If it does not work.
| When | Next move |
|---|---|
attempt-failed | The attempt failed. Read diagnostic and remediation: they name the classified cause and the next action, and failureSnippet carries what your receiver said. |
receiver-unreachable | Nothing reached your receiver. Confirm the URL answers a POST from the public internet, and that it is not behind an allowlist Greatwait is not on. |
9. Clean up
DELETE /v1/endpoints/{endpointId} — endpoints.disable
Stops delivery to the destination you registered for this walkthrough. Disabling is deliberate rather than final: it leaves the history intact rather than deleting evidence of what happened, and endpoints.enable turns the destination back on if you want it again.
What you should see. The endpoint in status disabled. Nothing further is delivered to it, and it stays readable so the attempts above keep their context.
If it does not work.
| When | Next move |
|---|---|
not_found | Already gone, or never in this project. Nothing to clean up. |
The whole thing
This is the file the hosted journey test executes, byte for byte, so what is printed here is what was run.
#!/usr/bin/env bash
# Generated from the first-value journey @greatwait/contracts publishes.
# Do not edit: it is regenerated whenever the journey changes.
#
# Needs curl 8.3.0 or newer and jq. The credential is
# imported by name with --variable, so it never appears in the command
# line where another user on this machine could read it out of `ps`.
set -euo pipefail
: "${GREATWAIT_API_URL:?API origin: The API origin for the environment you are working against, with no trailing slash.}"
: "${GREATWAIT_API_KEY:?API credential: The API credential secret, shown once when you created it. Export it in your shell; do not paste it into a command, a URL, or a file you commit.}"
: "${GREATWAIT_RECEIVER_URL:?Receiver URL: Your own HTTPS receiver. Every callback in this journey is delivered here and nowhere else.}"
GREATWAIT_IDEMPOTENCY_KEY="${GREATWAIT_IDEMPOTENCY_KEY:-first-value-$(date +%s)-$}"
if ! command -v jq >/dev/null 2>&1; then echo "this journey needs jq on your PATH" >&2; exit 1; fi
# Compared by version rather than probed with `--help`: curl does not
# list the --expand-* options there, so a probe that greps for one
# reports every curl as too old — including the 8.7 that ran this.
have="$(curl --version | head -1 | awk '{print $2}')"
need="8.3.0"
if [ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -1)" != "$need" ]; then
echo "this journey needs curl $need or newer for --variable and --expand-header, which keep the credential out of the command line; yours is $have" >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
BODY="$WORK/body"
HEADERS="$WORK/headers"
step() { printf '\n== %s\n' "$1"; }
# call METHOD PATH [JSON BODY] [extra curl args...]
call() {
local method="$1" path="$2" body="$3"; shift 3
# An array, not `${body:+-H "Content-Type: ..."}`. An unquoted
# expansion is word-split *after* substitution, so the quotes inside it
# are ordinary characters and curl receives three arguments — `-H`,
# `"Content-Type:` and `application/json"` — which is a header nobody
# sent and a refusal nobody can explain.
local -a payload=()
if [ -n "$body" ]; then
payload=(-H "Content-Type: application/json" --data-binary "$body")
fi
local status
status="$(curl -sS -X "$method" "${GREATWAIT_API_URL%/}$path" \
--variable %GREATWAIT_API_KEY \
--expand-header "Authorization: Bearer {{GREATWAIT_API_KEY}}" \
-D "$HEADERS" -o "$BODY" -w '%{http_code}' \
"$@" ${payload[@]+"${payload[@]}"})"
# **`000` is curl's answer when the transfer never happened at all** —
# DNS failure, connection refused, TLS refused, timeout. It is not a
# status, and `[ 000 -ge 400 ]` is false, so a plain status test reads a
# host that does not exist as a success, returns 0, and the reader gets
# `$BODY: No such file or directory` from the next line instead of the
# `no-response` guidance this journey promises them.
if [ "$status" = "000" ]; then
printf '%s %s got no response at all — the request never completed\n' \
"$method" "$path" >&2
return 1
fi
if [ "$status" -ge 400 ]; then
printf '%s %s refused with HTTP %s (%s)\n' "$method" "$path" "$status" \
"$(jq -r '.code // "no problem document"' <"$BODY" 2>/dev/null || echo "no problem document")" >&2
jq -r '.detail // .title // empty' <"$BODY" 2>/dev/null >&2 || true
return 1
fi
}
# The value of one response header, lower-cased name, or the empty string.
header() {
tr -d '\r' <"$HEADERS" | awk -v name="$(printf '%s' "$1" | tr 'A-Z' 'a-z')" \
'BEGIN{FS=": "} tolower($1)==name {print substr($0, index($0,": ")+2)}' | tail -n 1
}
step "1/9 Check which service and environment you reached"
# Confirms the origin you are pointed at is a Greatwait API and says which environment it serves. It needs no credential, so it separates 'wrong URL' from 'wrong key'.
if ! call GET "/__greatwait/version" ""
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'unexpected-answer' 'Something answered, but not this service. Check GREATWAIT_API_URL — a 404 here comes from a proxy in front of the API rather than from the API, so it carries no problem document to read.' >&2
printf ' %s: %s\n' 'no-response' 'The origin did not answer. Check the hostname and that you can reach it from this network before going further.' >&2
exit 1
fi
jq -c '.' <"$BODY"
step "2/9 Check the credential"
# Proves the credential works and is bound to the project you think it is, before you create anything with it. A credential names exactly one project, and this is the cheapest call that says so.
if ! call GET "/v1/schedules?limit=1" ""
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'authentication_required' 'No credential reached the service. Export GREATWAIT_API_KEY in this shell and try again.' >&2
printf ' %s: %s\n' 'credential_revoked' 'The credential was revoked. Create a new one in the dashboard; a revoked credential never comes back.' >&2
printf ' %s: %s\n' 'credential_expired' 'The credential expired. Rotate it in the dashboard and export the new secret.' >&2
printf ' %s: %s\n' 'scope_missing' 'The credential is missing schedules:read. Create one with the scopes this journey needs rather than widening this one.' >&2
exit 1
fi
jq -c '.' <"$BODY"
step "3/9 Create the destination"
# Registers the address every occurrence of this schedule is delivered to, and mints the per-endpoint signing key your receiver verifies callbacks with.
if ! call POST "/v1/endpoints" "$(jq -nc --arg GREATWAIT_RECEIVER_URL "$GREATWAIT_RECEIVER_URL" '{"url":$GREATWAIT_RECEIVER_URL}')"
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'invalid_request' 'The URL was refused. It must be HTTPS on port 443, carry no credentials or fragment, and resolve to a public address — a localhost or private-range host cannot be reached from the service.' >&2
printf ' %s: %s\n' 'plan_limit_reached' 'This project is at its destination ceiling. Disable a destination you no longer deliver to; disabled ones still count, because they stay readable for audit.' >&2
printf ' %s: %s\n' 'state_conflict' 'An active destination already delivers to this address in this project — usually one a previous run of this journey created. Reuse that destination, disable it first, or give the new one a name of its own.' >&2
exit 1
fi
jq -c 'del(.secret)' <"$BODY"
printf ' %s captured, not printed\n' 'GREATWAIT_ENDPOINT_SECRET'
GREATWAIT_ENDPOINTID="$(jq -r '.id' <"$BODY")"
# Yours to keep, for verifying callback signatures. Assigned and
# never read again by this script, deliberately: the value belongs
# to the reader and is not printed.
# shellcheck disable=SC2034
GREATWAIT_ENDPOINT_SECRET="$(jq -r '.secret' <"$BODY")"
step "4/9 Create the one-shot"
# Creates the one-shot. The delay is converted to an absolute instant when the request is accepted, so the schedule reads back the same afterwards however it was expressed.
if ! call POST "/v1/schedules" "$(jq -nc --arg GREATWAIT_ENDPOINTID "$GREATWAIT_ENDPOINTID" '{"data":{"greeting":"first value"},"delaySeconds":30,"endpoint":$GREATWAIT_ENDPOINTID}')" \
-H "Idempotency-Key: $GREATWAIT_IDEMPOTENCY_KEY"
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'not_found' 'The endpoint identifier is not one this credential'\''s project can deliver to — it belongs to another project, or there is no such endpoint. Create a destination in this project, or check the identifier.' >&2
printf ' %s: %s\n' 'state_conflict' 'The endpoint is disabled. Enable it with `endpoints.enable` and the schedule is accepted as sent, or point the schedule at another destination.' >&2
printf ' %s: %s\n' 'idempotency_key_reused' 'The same idempotency key was used for a different request. Use a new key, or send the byte-identical body the key was first used with.' >&2
printf ' %s: %s\n' 'invalid_request' 'Give exactly one of at, delaySeconds, everySeconds and cron, and make sure the endpoint identifier is one this credential'\''s project owns.' >&2
printf ' %s: %s\n' 'plan_limit_reached' 'This organization is at a plan ceiling — live schedules, distinct partition keys, or payload size. Nothing was created; reduce the payload or archive a schedule and retry.' >&2
printf ' %s: %s\n' 'service_unavailable' 'The service could not accept the schedule right now. This is safe to retry with the same idempotency key, which is what the key is for.' >&2
exit 1
fi
jq -c '.' <"$BODY"
GREATWAIT_SCHEDULEID="$(jq -r '.schedule.id' <"$BODY")"
GREATWAIT_OCCURRENCEID="$(jq -r '.occurrence.id' <"$BODY")"
step "5/9 Retry it safely"
# Repeats the previous call byte for byte, with the same key. This is the retry a network failure would have made for you, and it is the reason a create is safe to retry at all.
if ! call POST "/v1/schedules" "$(jq -nc --arg GREATWAIT_ENDPOINTID "$GREATWAIT_ENDPOINTID" '{"data":{"greeting":"first value"},"delaySeconds":30,"endpoint":$GREATWAIT_ENDPOINTID}')" \
-H "Idempotency-Key: $GREATWAIT_IDEMPOTENCY_KEY"
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'idempotency_key_reused' 'The body differed from the first call. A replay must be byte-identical; a changed body with a used key is a conflict rather than a second write.' >&2
exit 1
fi
jq -c '.' <"$BODY"
GREATWAIT_REPLAYED="$(header 'Idempotency-Replayed')"
GREATWAIT_REPLAYED_SCHEDULEID="$(jq -r '.schedule.id' <"$BODY")"
if [ "$GREATWAIT_REPLAYED" != "true" ]; then
echo "the second create was not reported as a replay, which means it may have created a second schedule — read your schedule listing before creating anything else" >&2
exit 1
fi
if [ "$GREATWAIT_REPLAYED_SCHEDULEID" != "$GREATWAIT_SCHEDULEID" ]; then
echo "the replay returned a different schedule from the one the first call created" >&2
exit 1
fi
step "6/9 Read the schedule back"
# Reads the schedule back from the partition that owns it, which is also where the version you need for a conditional write comes from.
if ! call GET "/v1/schedules/${GREATWAIT_SCHEDULEID}" ""
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'not_found' 'A schedule in another organization, project or environment answers not_found rather than a refusal. Check that this credential belongs to the project that created it.' >&2
exit 1
fi
jq -c '.' <"$BODY"
step "7/9 Wait for the occurrence to settle"
# Waits for the occurrence to settle. Delivery is at-least-once and a failing attempt is retried, so the state moves through `due` and `dispatching` before it settles.
# Poll until the occurrence reaches a terminal state, or until the deadline passes.
deadline=$(( $(date +%s) + 180 ))
settled=
while [ "$(date +%s)" -lt "$deadline" ]; do
call GET "/v1/schedules/${GREATWAIT_SCHEDULEID}/occurrences/${GREATWAIT_OCCURRENCEID}" ""
state="$(jq -r '.occurrence.state' <"$BODY")"
printf ' %s\n' "$state"
case "$state" in
succeeded|terminal_failed|cancelled|skipped_stale) settled=1; break ;;
esac
sleep 5
done
if [ -z "$settled" ]; then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'not-settled' 'The occurrence never settled inside the deadline. It is not lost: read it again later, and read its attempts to see how far delivery got.' >&2
exit 1
fi
step "8/9 Read the delivery attempt"
# Reads the immutable delivery history. This is the point the journey exists for — the customer-readable outcome of a real call to their own address.
if ! call GET "/v1/schedules/${GREATWAIT_SCHEDULEID}/occurrences/${GREATWAIT_OCCURRENCEID}/attempts" ""
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'attempt-failed' 'The attempt failed. Read `diagnostic` and `remediation`: they name the classified cause and the next action, and `failureSnippet` carries what your receiver said.' >&2
printf ' %s: %s\n' 'receiver-unreachable' 'Nothing reached your receiver. Confirm the URL answers a POST from the public internet, and that it is not behind an allowlist Greatwait is not on.' >&2
exit 1
fi
jq -c '.' <"$BODY"
step "9/9 Clean up"
# Stops delivery to the destination you registered for this walkthrough. Disabling is deliberate rather than final: it leaves the history intact rather than deleting evidence of what happened, and `endpoints.enable` turns the destination back on if you want it again.
if ! call DELETE "/v1/endpoints/${GREATWAIT_ENDPOINTID}" ""
then
printf 'what to do next:\n' >&2
printf ' %s: %s\n' 'not_found' 'Already gone, or never in this project. Nothing to clean up.' >&2
exit 1
fi
jq -c '.' <"$BODY"
printf '\nFirst value reached: the callback was made and its outcome is above.\n'
What the journey demonstrates
Every claim below is something the script above actually does, rather than a paragraph beside it.
Time. The one-shot is expressed as delaySeconds, and the service
converts it to an absolute instant when it accepts the request — so the
schedule reads back afterwards exactly as one created with at would.
Every instant in the API is RFC 3339, UTC, milliseconds, literal Z.
Idempotency. The create carries an Idempotency-Key, and the step after
it sends the byte-identical request again. The second call returns the same
schedule with Idempotency-Replayed: true and creates nothing further. The
same key with a different body is refused idempotency_key_reused rather
than silently writing twice.
Conditional writes. Reading the schedule returns its version as a strong
ETag. Send that value back in If-Match on a later pause, replace or
cancel and the write is refused version_conflict if anything changed in
between, instead of resolving by luck.
At-least-once delivery and retry. A failing attempt is retried, so the
occurrence moves through due and dispatching before it settles and may
record more than one attempt. Your receiver must tolerate seeing the same
occurrence twice. Every retry carries a new delivery identifier, so dedupe
the work on the occurrence: claim Callback-Occurrence-Id before running it,
and answer 200 to a later automatic delivery of an occurrence you have
already handled. A repeated delivery identifier is a replayed request.
Exact-body signature verification. Each callback is signed over the
exact request bytes with the per-endpoint secret returned by the create call
and by nothing else. Verify before parsing — @greatwait/api-client exports
createCallbackVerifier and createNodeCallbackMiddleware for this, and
Verifying callbacks is the full receiver contract.
Pagination. Listings are cursor-paged, never offset-paged, and every
page carries freshness. The attempt listing this journey reads is
authoritative; a cross-partition listing may be projected, which carries
asOf and lagSeconds so a client can tell fresh from stale.
Problem details. Every refusal is an RFC 9457 document with a stable
code. Branch on the code, never on the prose or the bare status. The
tables above list the codes each step can produce and what to do about each.
Cleanup. The last step disables the destination, which stops delivery without deleting the attempt history that recorded what happened.