Changing data
Who can write
A key changes data only when all of these hold:
- The office owner has switched changes on, once, under Settings › API and webhooks › Changes through the API. It needs a confirmed e-mail address and, when two-step verification is on, a session confirmed with the code. Until then keys can only read and
/mesayswrites: not_activated. - The key has the write scope of the resource. Keys with any write scope can be created or replaced only by an owner who has two-step verification switched on and confirmed this sign-in with the code.
- The key's creator could make the same change in Estalo. A write runs as that person, through the same rules as the web and mobile apps: office membership, role, which records are assigned to them, plan limits, duplicate checks. An agent's key changes only the agent's records. Assigning a record to someone (
assigned_user_id) is for owners, managers and directors on an office plan. - The key's creator still has two-step verification on. If they switch it off, the key keeps reading but every write answers
401 api_key_inactiveuntil they switch it on again. - The creator is working in the key's office at that moment. If they switched to another office in Estalo, writes answer
409 creator_context_mismatchuntil they switch back; nothing is written elsewhere.
The owner can switch changes off again in the same place (it needs a sign-in confirmed with the code when two-step verification is on). Write keys then keep reading, every write answers 403 writes_not_enabled, and no new write key can be created until changes are switched on again.
The database checks the scope again on every row it writes, so a key without clients:write cannot insert a client even through a code path we forgot.
Operations
| Method and path | Does | Success |
|---|---|---|
POST /api/v1/{resource} | creates a record | 201, { "data": record } |
PATCH /api/v1/{resource}/{id} | changes only the fields you send; null clears a field where that's allowed | 200, { "data": record } |
DELETE /api/v1/clients/{id} | archives the client (status archived, reversible) | 200, { "data": { "id": 42, "result": "archived" } } |
DELETE /api/v1/properties/{id} | moves the listing to the trash (restorable in Estalo for 30 days) | 200, result: "trashed" |
DELETE /api/v1/events/{id}, DELETE /api/v1/tasks/{id} | deletes it, as the app does | 200, result: "deleted" |
Inquiries and sales have no DELETE: change their status or stage. The response carries the record in the same shape as GET when the key also has the read scope, otherwise only { "id": … }.
Bodies are JSON objects (Content-Type: application/json) of at most 64 KB. A field that is unknown or read-only answers 400 invalid_request with param; nothing is silently dropped. The writable fields per resource are in the OpenAPI document (ClientCreate, ClientUpdate, …). In short:
| Resource | Writable fields | Required on POST |
|---|---|---|
| clients | first_name, last_name, name, email, phone, status, type, budget | name, or first_name and last_name |
| properties | title, price, type, transaction_type, market, status, address_city, address_street, address_district, area, rooms, floor, total_floors, build_year, description; assigned_user_id on PATCH | title, price, type, address_city |
| deals | title, value, stage, client_id, property_id, close_date, assigned_user_id | title, value, stage |
| events | title, type, start_time, end_time, client_id, property_id; outcome on PATCH | title, start_time, end_time |
| leads | first_name, last_name, customer_name, email, phone, message, source, status; assigned_user_id on PATCH | customer_name, or first_name and last_name |
| tasks | title, status, priority, task_type, due_date, related_client_id, related_property_id, related_deal_id, assigned_user_id | title |
Notes: timestamps need a time zone. A task links to at most one record: sending one related_* replaces the link, null removes it. Setting a task's status to done stamps completed_at; an event's outcome stamps outcome_at. A lead's message is stored as the inquiry text but returned by GET only for e-mail imports; source: email_import is reserved. Commission is not part of the API: a sale created through it has none until someone fills it in Estalo.
Idempotency
POST requires an Idempotency-Key header; on PATCH and DELETE it's optional but recommended. Use a new random value (a UUID is fine, up to 128 visible ASCII characters) for each change and reuse it only to retry that change. For 24 hours:
- the same key with the same method, path and body returns the first response again, with
Idempotent-Replayed: true, and changes nothing; - the same key with a different request answers
422 idempotency_key_reused; - while the first request is still running,
409 idempotency_in_progress: retry in a moment.
Refusals decided after the request was accepted (for example 409 duplicate or 402 plan_limit) are stored and replayed like successes. A 5xx is not stored, so you can retry with the same key.
Concurrent edits
PATCH accepts expected_updated_at: the updated_at you read before deciding on the change. If the record has changed since, the answer is 409 conflict with the current value in details.updated_at; fetch it again and decide again. Without it, the fields you send overwrite whatever is there (other fields are never touched).
Examples
Replace the key and ids with your own. Every POST needs its own Idempotency-Key.
KEY="est_live_xxxxxxxxxxxx_<your-key>"
# Client
curl -s -X POST https://estalo.pl/api/v1/clients \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"first_name":"Anna","last_name":"Nowak","phone":"+48 600 000 000","type":"buyer"}'
curl -s -X PATCH https://estalo.pl/api/v1/clients/42 \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"status":"contacted","expected_updated_at":"2026-11-02T09:15:02.114+00:00"}'
curl -s -X DELETE https://estalo.pl/api/v1/clients/42 -H "Authorization: Bearer $KEY"
# Listing
curl -s -X POST https://estalo.pl/api/v1/properties \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"title":"2 pokoje, Jeżyce","price":649000,"type":"flat","address_city":"Poznań","area":48.5,"rooms":2}'
curl -s -X DELETE https://estalo.pl/api/v1/properties/0f6c2a1e-0000-4000-8000-000000000000 -H "Authorization: Bearer $KEY"
# Sale
curl -s -X POST https://estalo.pl/api/v1/deals \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"title":"Jeżyce 2 pokoje","value":649000,"stage":"new","client_id":42,"property_id":"0f6c2a1e-0000-4000-8000-000000000000"}'
curl -s -X PATCH https://estalo.pl/api/v1/deals/7d1e0c2b-0000-4000-8000-000000000000 \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"stage":"negotiation"}'
# Calendar event
curl -s -X POST https://estalo.pl/api/v1/events \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"title":"Prezentacja","type":"meeting","start_time":"2026-11-05T10:00:00+01:00","end_time":"2026-11-05T11:00:00+01:00","client_id":42}'
# Inquiry (e.g. from your website form)
curl -s -X POST https://estalo.pl/api/v1/leads \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"customer_name":"Jan Kowalski","email":"jan@example.com","message":"Czy oferta jest aktualna?","source":"www"}'
curl -s -X PATCH https://estalo.pl/api/v1/leads/a5d0c3f1-0000-4000-8000-000000000000 \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"status":"contacted"}'
# Task
curl -s -X POST https://estalo.pl/api/v1/tasks \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"title":"Oddzwonić","task_type":"call","due_date":"2026-11-03T09:00:00Z","related_client_id":42}'
curl -s -X PATCH https://estalo.pl/api/v1/tasks/3b9e1d40-0000-4000-8000-000000000000 \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"status":"done"}'
Errors
Every error has the same shape and a stable code:
{ "error": { "code": "insufficient_scope", "message": "…", "request_id": "…" } }
Some errors add param (the parameter, header or field at fault) and details (ids and field names, never another person's data).
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | bad id, filter, limit, cursor, unknown parameter or unknown/read-only field, missing Idempotency-Key (param names it) |
| 401 | invalid_api_key | missing, malformed or unknown key |
| 401 | api_key_expired | past its expiry |
| 401 | api_key_inactive | revoked, or its creator can no longer act in the office; on writes also when the creator switched two-step verification off (reads keep working) |
| 402 | workspace_locked | the office has no active plan or is suspended |
| 402 | plan_limit | write: the plan's limit is reached or the plan lacks the feature (e.g. sales) |
| 403 | api_not_enabled | the beta is not switched on for this office |
| 403 | insufficient_scope | the key lacks the resource's scope (<resource>:write for changes) |
| 403 | writes_not_enabled | write: the office has not switched changes on, or the owner switched them off (reads keep working) |
| 403 | forbidden | write: the key's creator may not make this change (e.g. assigning records without a leadership role on an office plan) |
| 404 | not_found | no such record visible to this key, or unknown path |
| 405 | method_not_allowed | the method is not offered on this path; Allow lists the ones that are |
| 409 | conflict | write: the record changed since your expected_updated_at; details.updated_at is the current value |
| 409 | duplicate | write: a client with this phone or e-mail exists; details.duplicate has its field and id |
| 409 | creator_context_mismatch | write: the key's creator is working in another office right now |
| 409 | idempotency_in_progress | write: a request with this Idempotency-Key is still running |
| 413 | payload_too_large | write: body over 64 KB |
| 415 | unsupported_media_type | write: body not sent as application/json |
| 422 | validation_failed | write: a value is invalid; details.fields (or details.reason) says which |
| 422 | idempotency_key_reused | write: this Idempotency-Key was used with a different request |
| 429 | rate_limited | see below; wait Retry-After seconds |
| 500 | internal_error | our side; quote request_id if you contact us |
Branch on code, not on message. Responses carry Cache-Control: no-store and X-Request-Id.