Skip to content
All pages

Concepts

Idempotency: retries that cannot double-bill

A report costs a credit and a network can always time out between your request and our answer. The Idempotency-Key header is how a retry becomes the same request rather than a second one.

The contract

Choose a key that names the intent, such as your claim number, and send it on the first attempt and every retry. Two requests carrying the same key are one request:

  • The first attempt does the work and answers normally, a 201 or a 202.
  • A retry answers 200 with the same body and an Idempotent-Replay: true header, and creates and bills nothing.
  • A retry that arrives while the first attempt is still running answers 409; wait and retry with the same key.
  • The same key with a different method, path or body is a 409: a key names one request, and reusing it for another is always a bug worth hearing about.

Keys are honoured for 24 hours, scoped to your team and to the endpoint. Replays re-read the record as it stands, so retrying a create after the record was deleted answers with what the create made, and never re-bills. Errors are never stored: a 422 or a 402 on the first attempt leaves the key unused, and the retry runs fresh.

Which endpoints honour the header is stated in the reference beside each one. The handful of creating endpoints that do not are the ones with a natural key of their own, such as a property's address, where a retry is already safe; each says so in its reference entry.

A retry loop worth copying
// One stable key per intent, decided before the first attempt.
const key = `claim-${claim.id}-first-report`;

async function createReport(payload) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const response = await fetch(`${BASE}/api/v1/reports`, {
      method: 'POST',
      headers: {
        ...auth,
        'Idempotency-Key': key,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    });

    // 202 the first time, 200 with Idempotent-Replay on a retry.
    if (response.ok) return response.json();

    // 409: the first attempt is still in flight. Wait, then retry
    // with the same key; never mint a new one.
    await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
  }

  throw new Error('report creation did not settle in three attempts');
}

Worked: creating a report

Create a report

POST /api/v1/reports

Ask for a report. Answers 202; poll links.self for the result.

reports:write Idempotency-Key honoured

Body

address string required

The property address, as a person would write it. Located for you; the response says where it resolved.

window_start string required

The first day of the period to search, YYYY-MM-DD. Must be in the past.

window_end string required

The last day of the period, on or after window_start.

radius_miles number

How far around the address to search, 1 to 100. The plan may narrow it; window.narrowed_by_plan says so.

reference string

Your own file or claim number, carried on the report unchanged.

recipient string

Who the document is prepared for, printed on the cover.

property_id string

A saved property to file this report under.

layout array

Which sections, imagery and map layers the document is composed with. Requires a plan that can compose layouts; otherwise a 402 names the tier.

Answers

200

Replayed. This Idempotency-Key was honoured before; the earlier answer is returned again, with an Idempotent-Replay: true header.

202

Accepted. The report exists but is not yet generated; poll links.self for the result.

409

This Idempotency-Key was already used for a different request, or its first request is still being processed. A retry must resend the request it is retrying.

Plus the shared refusals: 401, 402, 403, 404, 422 and 429, described on Errors.

POST /api/v1/reports
curl -X POST https://titanweather.com/api/v1/reports \
  -H "Authorization: Bearer $TITAN_API_TOKEN" \
  -H "Idempotency-Key: claim-48211-first-report" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "1428 Cedar Ridge Dr, Plano, TX 75023",
    "window_start": "2016-08-23",
    "window_end": "2026-08-23",
    "radius_miles": 5
}'
Response 202
{
    "data": {
        "id": "0198b6c1-22e4-7f88-a1c9-5d40b7e2913a",
        "object": "report",
        "status": "pending",
        "address": {
            "query": "1428 Cedar Ridge Dr, Plano, TX 75023",
            "street": null,
            "city": null,
            "state": null,
            "postal_code": null,
            "county": null,
            "latitude": null,
            "longitude": null
        },
        "window": {
            "start": "2016-08-23",
            "end": "2026-08-23",
            "radius_miles": 5,
            "narrowed_by_plan": false
        },
        "property_id": null,
        "event_count": 0,
        "observation_count": 0,
        "coverage": {
            "complete": false,
            "provisional": false,
            "summary": null,
            "sources_searched": []
        },
        "headline": null,
        "findings": null,
        "failure_reason": null,
        "plan_tier": "professional",
        "layout": {
            "sections": [
                "summary",
                "findings",
                "map",
                "events"
            ],
            "imagery": [
                "aerial",
                "street_view"
            ],
            "elements": [
                "key_figures"
            ],
            "map": null
        },
        "created_at": "2026-08-23T14:02:11Z",
        "generated_at": null,
        "pdf": {
            "status": null,
            "rendered_at": null
        },
        "links": {
            "self": "https://titanweather.com/api/v1/reports/0198b6c1-22e4-7f88-a1c9-5d40b7e2913a",
            "events": null,
            "pdf": null
        }
    }
}
esc
move open 72 places