Responses and errors describes what a failure looks like, and Retries when repeating a call is safe. This guide applies both to three situations every integration meets: a booking that timed out, a version conflict on an update, and a 429. Read those two pages first; this one doesn’t repeat them. The examples use curl and jq, with an API key in ARBOL_API_KEY, and every id and patient detail in them is an example.
Keep what each answer says
Each decision below rests on the status, error.code, error.retryable and, when present, the Retry-After header. Keep the headers and the body of each answer apart, and log metadata.requestId with every failure:
status=$(curl --silent --show-error --max-time 30 \
--dump-header headers.txt --output body.json --write-out "%{http_code}" \
https://api.getarbol.com/v1/agenda/booking-requirements \
--header "Authorization: Bearer $ARBOL_API_KEY")
jq --arg status "$status" '{$status, code: .error.code, retryable: .error.retryable, requestId: .metadata.requestId}' body.jsonWhen no answer arrives at all, curl exits with an error, such as 28 when --max-time runs out, and the status is 000. For a write, that’s an outcome you don’t know yet.
A booking that timed out
You sent agenda.bookings.create and got no answer, a 504 UPSTREAM_TIMEOUT or a 502 OUTCOME_UNKNOWN. Whatever retryable says, the appointment may exist, and the booking operations take no idempotency key. Find out before you do anything else:
-
Hold on. Don’t offer the patient another time, and don’t tell them it failed: you don’t know yet.
-
Look for the appointment.
agenda.bookings.lookupis a read, so you can repeat it until it answers:curl https://api.getarbol.com/v1/agenda/bookings/lookup \ --request POST \ --header "Content-Type: application/json" \ --data '{ "documentNumber": "1234567890", "documentType": "CC" }' \ --header "Authorization: Bearer $ARBOL_API_KEY" \ | jq '.data.appointments[] | {id, startsAt, offeringId, status}'Look for an appointment of the slot’s
offeringIdthat starts at the slot’sstartsAtand isn’tcancelled. Compare instants, not text: the same time can be written with different offsets. -
If it’s there, the booking went through. Read it with
agenda.bookings.getfor its guidance and slip, and confirm it to the patient. -
If it isn’t, send the same request again, with the same
slotToken.agenda.bookings.createis idempotent: repeating it with the same input has no further effect. If it now answersINVALID_SLOT_TOKEN,RESOURCE_CAPACITY_EXCEEDEDorSCHEDULE_CHANGED, the time is gone: find another slot and ask the patient again.
The other booking writes need the same care, each according to its Effect:
| Operation | Effect | When its outcome is unknown |
|---|---|---|
agenda.bookings.reschedule |
Writes; not idempotent | Read the appointment with agenda.bookings.get. If it already starts at the new time, it moved; repeat only if it didn’t. |
agenda.bookings.cancel |
Deletes; idempotent | Repeat it, or read the appointment and check for cancelled. |
agenda.waitlist.join |
Writes; idempotent | Repeat it: the same patient, service and reason return the same open entry. |
A version conflict on an update
An update that takes expectedVersion applies only if nobody changed the record since you read it. Here a sync job sets a Raíz contact’s summary with raiz.contacts.update, which needs raiz:contacts:write, sending the version that raiz.contacts.get answered:
curl https://api.getarbol.com/v1/raiz/contacts/cnt_00000000-0000-4000-8000-000000000009 \
--request PATCH \
--header "Authorization: Bearer $ARBOL_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"expectedVersion": 7,
"summary": "Prefers to be contacted in the morning."
}'Someone changed the contact in between, so it answers 409 with this error:
{
"code": "VERSION_CONFLICT",
"message": "The resource changed after it was read. Read it again and retry with the new version.",
"retryable": false,
"data": {}
}retryable is false: the same request would fail the same way. Instead:
- Read the record again with
raiz.contacts.get, for its current content andversion. - Check whether your change is already there. Repeating an update that already applied, such as your own earlier attempt that timed out, also answers
VERSION_CONFLICT. If the change is there, you’re done. - Apply your change to what you just read, keeping the other writer’s changes, and send it with the new
version. - Stop after a few conflicts in a row, and report them with their request ids: something else keeps changing the record.
Agenda’s updates that take a version, of services, offerings, resources, schedule rules and appointments, accept only people, so they come from staff tools. They answer 409 with a code that names the record, and you handle each the same way:
error.code |
Message |
|---|---|
SERVICE_VERSION_MISMATCH |
The service changed after it was loaded. |
OFFERING_VERSION_MISMATCH |
The offering changed after it was loaded. |
RESOURCE_VERSION_MISMATCH |
The resource changed after it was loaded. |
SCHEDULE_RULE_VERSION_MISMATCH |
The schedule rule changed after it was loaded. |
SCHEDULE_RULES_CHANGED |
The schedule changed after it was loaded. |
APPOINTMENT_VERSION_MISMATCH |
The appointment changed after it was loaded. |
A 429
429 RATE_LIMITED is retryable: wait the seconds in Retry-After, then send the same request again. The limit counts the requests of one credential, which is one more reason to give each system its own key. Wrap your calls so every flow waits the same way; in TypeScript, arbol is the client from createClient, as in the TypeScript SDK:
# Sends one request; while the answer is 429, waits Retry-After seconds and sends it again.
arbol_request() {
local attempt status delay
for attempt in 1 2 3 4 5; do
status=$(curl --silent --show-error --max-time 30 \
--dump-header headers.txt --output body.json --write-out "%{http_code}" \
--header "Authorization: Bearer $ARBOL_API_KEY" "$@") || return 1
[ "$status" = 429 ] || break
delay=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' headers.txt)
sleep "${delay:-$((2 ** attempt))}"
done
cat body.json
}
arbol_request --get https://api.getarbol.com/v1/agenda/nearest-booking-slots \
--data-urlencode "serviceId=svc_00000000-0000-4000-8000-000000000001" \
--data-urlencode "timezone=America/Bogota"import { ApiError } from "@arbol-ai/sdk";
// Calls the API; while the answer is 429, waits Retry-After seconds and calls again.
async function withRetryAfter<T>(call: () => Promise<T>, attempts = 5): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await call();
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 429 || attempt === attempts) throw error;
const seconds = Number(error.response.headers.get("retry-after")) || 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
}
}
const { data } = await withRetryAfter(() =>
arbol.agenda.nearestBookingSlots.list({ serviceId: "svc_00000000-0000-4000-8000-000000000001", timezone: "America/Bogota" }),
);Both stop after five attempts and give up at once when no answer arrives: for a write, that’s the unknown outcome above, and waiting doesn’t resolve it. Repeat a 503 or 504 the same way only for reads, with exponential backoff and jitter; a write that ended in either goes through the steps of a booking that timed out.