Errors and Limits
The error envelope, the codes to branch on, rate limits, and idempotent retries.
The error envelope
Every non-2xx response carries the same JSON envelope:
{
"error": {
"code": "consent_required",
"message": "Required agreements are unmet. Accept them, then retry.",
"details": { "agreements": [{ "url": "...", "version": "2", "sourceUrl": "..." }] }
}
}codeis stable and machine-readable: branch on it, not on the message.messageis human-readable and safe to show to the patient.detailscarries structured context for the codes that have it.
Business failures are real HTTP statuses. You will never get a 200 that is secretly a failure.
The codes
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | Missing, malformed, or expired bearer token. |
forbidden | 403 | Valid token, but not allowed (for example, not a patient session). |
invalid_request | 400 | Validation failed, or the platform rejected the request as invalid. |
not_found | 404 | No such endpoint or resource in the patient's record. |
unsupported_resource | 404 | The resource type is not part of the record plane. |
method_not_allowed | 405 | Not writable here; the message names the workflow endpoint that owns the change. |
conflict | 409 | Lost a race or hit a forbidding state: slot taken, task closed, already started. |
consent_required | 409 | Required agreements are unmet; details.agreements lists them. |
rate_limited | 429 | Too many requests; honor Retry-After. |
upstream_error | 502 | The platform behind the API failed to answer. Retry is reasonable. |
internal | 500 | An unexpected error on our side. |
Two 409s carry actionable details:
consent_required- agreements are unmet;details.agreementslists them. The clean flow checks requirements upfront withagreements.requirementsand records acceptances before acting, so this code is the server-enforced backstop, not something the patient routinely sees. Nothing was changed by the refused call, so retrying afteragreements.acceptis safe.conflict- readdetailsfor what happened (details.reason,details.notBefore,details.appointmentdepending on the endpoint) and route the user accordingly.
Handling errors in the SDK
The SDK throws on any non-2xx response and ships getApiError to recover the envelope from the thrown error:
import { getApiError } from '@palamond/sdk';
try {
await palamond.assessments.start({ healthcareService });
} catch (err) {
const api = getApiError(err);
if (api?.code === 'consent_required') {
// api.details.agreements lists what to accept.
} else {
throw err;
}
}getApiError returns undefined for anything that is not a Palamond API failure (network errors, plain FHIR outcomes), so rethrow those. The guides use it wherever a 409 is part of the normal flow.
Rate limits
Requests are limited per session over a sliding minute window. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 adds Retry-After in seconds. Back off and retry after that.
Idempotent retries
Workflow mutations accept an Idempotency-Key header; every mutation method takes it as an option. Retrying the same key on the same endpoint within 24 hours replays the recorded response instead of repeating the action, so a timed-out reschedule retried moves the visit once, not twice.
await palamond.appointments.reschedule(appointmentId, body, { idempotencyKey: crypto.randomUUID() });Use a fresh key per logical action and reuse it for retries of that action. Reads and the record plane do not need one.