# Zoodealio API reference > The Zoodealio public REST API: submit properties with their homeowner and photos, accept the estimate to start AI comping, request offer details and accept offers, read your team and get a webhook at every step. Authenticated with a personal API key. Human-readable version: https://zoodealio.ai/developers. Search it from an agent with the docs MCP server at https://zoodealio.ai/developers/mcp (no key needed). ## Quickstart: the whole journey Take a property from submission to an accepted offer, in the same steps the app walks you through. 1. **Get your API key.** Open Settings → API in the Zoodealio app and generate a key. It is shown once, so keep it in a secret manager or an environment variable, never in code. ```bash export ZOODEALIO_API_KEY="zoo_…" ``` 2. **Subscribe to webhooks.** Zoodealio tells you when each step finishes, so you never poll. `transaction.stage_changed` covers every step; the others mark the moments you act on. ```bash curl -X POST "https://api.zoodealio.ai/openapi/Webhooks" \ -H "ApiKey: $ZOODEALIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"endpointUrl":"https://you.example/zoodealio","eventTypes":["transaction.stage_changed","offer.created","property.files_imported","comping.completed","comping.failed","offer.details_shared","offer.accepted"]}' ``` 3. **Submit the property.** Send the address, the home’s details and the homeowner. Photos and documents ride along as URLs you host, as many as the listing has; Zoodealio imports them in the background. The property is priced on the spot and gets an estimated offer (`offer.created`). ```bash curl -X POST "https://api.zoodealio.ai/openapi/Properties" \ -H "ApiKey: $ZOODEALIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"address":{"addressLine1":"102 Shore Drive","city":"Youngsville","stateCode":"LA","postalCode":"70592","country":"US"},"attributes":{"bedroomCount":4,"bathroomCount":2.5,"squareFootage":2400,"yearBuilt":2016},"homeowner":{"email":"jane.doe@example.com","fName":"Jane","lName":"Doe","phone":"+15551234567"},"photos":[{"url":"https://cdn.example.com/102-shore/front.jpg"}]}' ``` Then wait for the `property.files_imported` webhook. 4. **Show the estimate.** Read the property’s offers. The estimate is `kind: "Estimated"` and carries only its range, `estimatedRange: { low, high }`. Keep its `id` for the next step. ```bash curl "https://api.zoodealio.ai/openapi/Offers?propertyId=$PROPERTY_ID" \ -H "ApiKey: $ZOODEALIO_API_KEY" ``` 5. **Accept the estimate: confirm details and start AI comping.** Accepting the estimate asks for real offers and starts AI comping. Before you do, have the homeowner confirm the home’s details and send them with it. Photos should already be in: send more first with `POST /openapi/Properties/{id}/photos` and wait for `property.files_imported`. Both are optional: accept with no body at all to skip them. ```bash curl -X POST "https://api.zoodealio.ai/openapi/Offers/$ESTIMATE_ID/accept" \ -H "ApiKey: $ZOODEALIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"attributes":{"bedroomCount":4,"bathroomCount":3,"squareFootage":2450,"yearBuilt":2016}}' ``` Then wait for the `comping.completed` webhook. 6. **Show the offers.** When comping completes, read the offers again. Each real offer is `kind: "Actual"` with its headline `amount`; its terms stay `null` until the next step. `revealUrl` on the comping status opens the full reveal in the app. ```bash curl "https://api.zoodealio.ai/openapi/Offers?propertyId=$PROPERTY_ID" \ -H "ApiKey: $ZOODEALIO_API_KEY" ``` 7. **Request the details.** Ask for the full terms of the offer the homeowner is interested in. Zoodealio finalizes it and shares the details. ```bash curl -X POST "https://api.zoodealio.ai/openapi/Offers/$OFFER_ID/request-details" \ -H "ApiKey: $ZOODEALIO_API_KEY" ``` Then wait for the `offer.details_shared` webhook. 8. **Accept the offer.** Read the offer to show its full terms, then accept it. The transaction moves to `OfferSelected` and contracting continues in the app. ```bash curl -X POST "https://api.zoodealio.ai/openapi/Offers/$OFFER_ID/accept" \ -H "ApiKey: $ZOODEALIO_API_KEY" ``` Then wait for the `offer.accepted` webhook. ### Journey stages A transaction’s `stage` moves through these in order. Every change arrives as `transaction.stage_changed`. | Stage | Moved by | What it means | | --- | --- | --- | | EstimatedOfferAvailable | You | Property submitted and priced; the estimate is ready. | | OfferRequested | You | Estimate accepted; AI comping is starting. | | CompingQueued | Zoodealio | AI comping is queued. | | CompingInProgress | Zoodealio | AI comping is running. | | CompingComplete | Zoodealio | Real offers are ready (`comping.completed`). | | CompingViewed | The app | Someone opened the offer reveal. | | DetailsRequested | You | Full details requested. | | OfferReady | Zoodealio | The offer is finalized. | | DetailsShared | Zoodealio | Full terms are readable (`offer.details_shared`). | | OfferSelected | You | Offer accepted (`offer.accepted`). | ## Getting started Base URL: `https://api.zoodealio.ai/openapi`. Requests and responses are JSON. Get a key on Settings → API in the Zoodealio app. ```bash curl "https://api.zoodealio.ai/openapi/Properties?page=1&pageSize=20" \ -H "ApiKey: zoo_your_key_here" curl "https://api.zoodealio.ai/openapi/Offers?propertyId=5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a" \ -H "ApiKey: zoo_your_key_here" ``` ### One base URL Every request goes to the same API host under `/openapi/`. There is one version of the API: no version segments and no duplicate routes. The only other URLs involved are your own webhook endpoints, which Zoodealio calls back. ### Authentication Send your API key in the `ApiKey` header on every request (`Authorization: Bearer ` also works). Keys look like `zoo_…` and are shown exactly once when generated or rotated, on Settings → API. The key belongs to you and uses your current role, team memberships and assignments, just like the app. ### The same actions as the app The API does what you can do in the app, and nothing more: submit and edit properties, add photos and documents, accept an estimate to start AI comping, request an offer’s details and accept an offer. Offers are made by Zoodealio, so they cannot be created or edited through the API. ### What you see, and when The same gate as the app. An estimate shows only its range. A real offer shows its headline amount once AI comping completes, and its full terms once you request details and they are shared. Internal notes, valuation evidence and comparable sales are never returned. ### Teams and workspace moves Owners and account admins have workspace access. Team admins have access through the teams they administer and every team below them. Members have access to their own homeowners and properties. Your key and webhook subscriptions stay with you when you move workspaces, and access is recalculated from your current workspace, including before each webhook delivery. ### Ids in the path, not the body Every update and delete addresses its record through the URL, e.g. `PUT /openapi/Properties/{id}`. Sending the id in the body against the collection path instead returns `405 Method Not Allowed`, because the collection has no PUT. ### Rate limiting Requests over the limit return `429 Too Many Requests` with a `Retry-After` header telling you how long to back off. ### Errors Failures come back as `application/problem+json` with a title and detail: 400 for validation, 401 for a bad key, 403 for an action your role cannot perform, 404 for a record outside your current scope, 409 for a conflict (a duplicate address, or a step taken out of order). ## Status codes | Status | Meaning | What to do | | --- | --- | --- | | 200 / 201 | Success. | Read the JSON body; a create returns the new `id`. | | 204 | Success with no body. | Nothing to read. | | 400 | Validation failed. | Fix the fields named in the problem `detail`; do not retry unchanged. | | 401 | Missing or invalid API key. | Send `ApiKey`; rotate the key if it leaked. | | 403 | Your role cannot do this. | Use a key whose owner has the permission. | | 404 | Not found, or outside your current access. | Check the id and that the record belongs to someone you can see. | | 405 | Wrong method for this path. | Put the id in the path, e.g. `PUT /openapi/Properties/{id}`. | | 409 | Conflict: a duplicate address, a stale `If-Match`, or a step taken out of order. | Re-read the transaction’s `stage` and take the step it is waiting for. | | 413 | Upload too large. | A multipart upload over about 4 MB. Send the files as URLs instead. | | 429 | Rate limited. | Wait the number of seconds in `Retry-After`, then retry. | ## Properties The core record. Submit a property with its homeowner, photos and documents, edit its details, and manage its files. Everything else hangs off a property. | Method | Full URL | Description | | --- | --- | --- | | GET | `https://api.zoodealio.ai/openapi/Properties` | List properties | | GET | `https://api.zoodealio.ai/openapi/Properties/{id}` | Get one property | | POST | `https://api.zoodealio.ai/openapi/Properties` | Submit a property; photo and document URLs import in the background | | PUT | `https://api.zoodealio.ai/openapi/Properties/{id}` | Correct the address or details | | GET | `https://api.zoodealio.ai/openapi/Properties/{id}/photos` | List photos | | POST | `https://api.zoodealio.ai/openapi/Properties/{id}/photos` | Import photos from URLs (background) | | DELETE | `https://api.zoodealio.ai/openapi/Properties/{id}/photos/{photoId}` | Delete a photo | | GET | `https://api.zoodealio.ai/openapi/Properties/{id}/documents` | List documents | | POST | `https://api.zoodealio.ai/openapi/Properties/{id}/documents` | Import documents from URLs (background) | | DELETE | `https://api.zoodealio.ai/openapi/Properties/{id}/documents/{documentId}` | Delete a document you uploaded | | GET | `https://api.zoodealio.ai/openapi/Properties/{id}/imports/{importId}` | Where a file import stands | - Submitting a property prices it straight away: it is born with its transaction and an estimated offer, exactly like one submitted in the app. The `offer.created` webhook tells you the estimate is ready. - Attach the homeowner inline with `homeowner` (`email`, `fName`, `lName`, `phone`), or pass `homeownerId` to reuse one you already created (every property returns its `homeownerId`). Send one or the other, not both. A new homeowner is notified and invited to their portal. - `agentId` picks the agent the property belongs to; omit it and it is yours. A second property at an address an agent already holds is a 409. - Send files as URLs you host (`photos: [{ url, label }]`, `documents: [{ url, fileName }]`). They can ride along with the submission, or be added any time with `POST /openapi/Properties/{id}/photos` and `/documents`. Up to 200 files per import, so a full listing goes in one call. - Files import in the background. The request answers at once with an `importId` and a `statusUrl` (202, or inside the 201 of a submission), and the `property.files_imported` webhook fires when every file is done. - The webhook and the status endpoint carry the same result: the saved `photoIds` and `documentIds`, and `failed: [{ source, reason }]` for each file that was not saved (a 404, not an image, too large). A failed file never fails the import. Send it again, fixed or on its own. - The URLs must be public HTTPS and serve the file itself; a short-lived signed link works well, as long as it outlives the import (a few minutes for a big set). Zoodealio fetches each file once and keeps its own copy. - Photos are JPEG, PNG or WebP, up to 10 MB each, and are stored at 2000 px on the long edge. More photos mean a more confident AI valuation; five or more is a good target. - Documents are PDF, Word, Excel, PowerPoint, RTF, text, CSV or images, up to 25 MB each from a URL. They appear on the property’s Documents tab in the app. You can delete the documents you uploaded. - A few local files can be uploaded directly instead: `multipart/form-data` to `/photos` or `/documents`, saved during the request. A request carries only about 4 MB, so URLs are the way to send more than a file or two. - PUT replaces `address` and `attributes` wholesale. `If-Match` is optional: send the ETag from a GET to reject a stale write. - List filters: `search` (free text over the address), `stateCode`, `page`, `pageSize`. ### POST /openapi/Properties: request ```json { "address": { "addressLine1": "102 Shore Drive", "addressLine2": "", "city": "Youngsville", "stateCode": "LA", "postalCode": "70592", "country": "US" }, "attributes": { "bedroomCount": 4, "bathroomCount": 2.5, "squareFootage": 2400, "yearBuilt": 2016 }, "homeowner": { "email": "jane.doe@example.com", "fName": "Jane", "lName": "Doe", "phone": "+15551234567" }, "photos": [ { "url": "https://cdn.example.com/listings/102-shore/front.jpg", "label": "Front" }, { "url": "https://cdn.example.com/listings/102-shore/kitchen.jpg", "label": "Kitchen" } ], "documents": [ { "url": "https://cdn.example.com/listings/102-shore/disclosure.pdf" } ] } ``` ### 201 Created: response (Location header carries the new property URL) ```json { "id": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "homeownerId": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "importId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "importStatusUrl": "/openapi/Properties/5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a/imports/7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d" } ``` ### GET /openapi/Properties/{id}: response ```json { "id": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "address": { "addressLine1": "102 Shore Drive", "addressLine2": "", "city": "Youngsville", "stateCode": "LA", "postalCode": "70592", "country": "US" }, "attributes": { "bedroomCount": 4, "bathroomCount": 2.5, "squareFootage": 2400, "yearBuilt": 2016 }, "latitude": 30.108, "longitude": -91.987, "agentId": "3f6c1a2e-9b4d-4c1f-8a2b-1d2e3f4a5b6c", "homeownerId": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "createdAt": "2026-09-24T12:00:00Z", "updatedAt": "2026-09-24T12:00:00Z", "eTag": "\"0x8DCB…\"", "activeTransactionId": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f" } ``` ### PUT /openapi/Properties/{id}: request ```json { "address": { "addressLine1": "102 Shore Drive", "addressLine2": "", "city": "Youngsville", "stateCode": "LA", "postalCode": "70592", "country": "US" }, "attributes": { "bedroomCount": 4, "bathroomCount": 3, "squareFootage": 2450, "yearBuilt": 2016 } } ``` ### POST /openapi/Properties/{id}/photos: request ```json { "photos": [ { "url": "https://cdn.example.com/listings/102-shore/front.jpg", "label": "Front" }, { "url": "https://cdn.example.com/listings/102-shore/kitchen.jpg", "label": "Kitchen" } ] } ``` ### 202 Accepted: response (the same for photos and documents) ```json { "importId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "statusUrl": "/openapi/Properties/5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a/imports/7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d" } ``` ### POST /openapi/Properties/{id}/documents: request ```json { "documents": [ { "url": "https://cdn.example.com/listings/102-shore/disclosure.pdf" }, { "url": "https://files.example.com/dl?id=8812", "fileName": "inspection.pdf" } ] } ``` ### A few local files instead: multipart, saved during the request (about 4 MB) ```json curl -X POST "https:///openapi/Properties/$id/photos" \ -H "ApiKey: $ZOODEALIO_API_KEY" \ -F "photos=@front.jpg" \ -F "photos=@kitchen.jpg" ``` ### GET /openapi/Properties/{id}/imports/{importId}: response ```json { "importId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "status": "completed", "total": 62, "processed": 62, "photoIds": ["c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", "…"], "documentIds": ["e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b"], "failed": [ { "source": "https://cdn.example.com/listings/102-shore/kitchen.jpg", "reason": "The URL answered 404." } ] } ``` ### GET /openapi/Properties/{id}/documents: response ```json [ { "id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b", "fileName": "disclosure.pdf", "size": 482113, "contentType": "application/pdf", "url": "https://…/property-documents/5d4c…/e5f6….pdf", "uploadedByUserId": "3f6c1a2e-9b4d-4c1f-8a2b-1d2e3f4a5b6c", "uploader": { "userId": "3f6c1a2e-9b4d-4c1f-8a2b-1d2e3f4a5b6c", "fName": "Alex", "lName": "Smith", "email": "alex.smith@example.com" }, "createdAt": "2026-09-24T12:00:00Z" } ] ``` ### GET /openapi/Properties/{id}/photos: response ```json [ { "id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", "url": "https://…/property-photos/5d4c…/c4d5….webp", "expiresAt": "2026-09-24T13:00:00Z", "order": 0, "createdAt": "2026-09-24T12:00:00Z", "label": "Front" } ] ``` ## Offers Read a property’s offers and take the two actions the app gives you: accept an offer, and request an offer’s full details. | Method | Full URL | Description | | --- | --- | --- | | GET | `https://api.zoodealio.ai/openapi/Offers?propertyId={propertyId}` | List a property’s offers: propertyId is required | | GET | `https://api.zoodealio.ai/openapi/Offers/{id}` | Get one offer | | GET | `https://api.zoodealio.ai/openapi/Offers/{id}/history` | Status changes over time | | POST | `https://api.zoodealio.ai/openapi/Offers/{id}/accept` | Accept the estimate (starts AI comping) or accept a final offer | | POST | `https://api.zoodealio.ai/openapi/Offers/{id}/request-details` | Ask for the offer’s full terms | - There are two kinds of offer. `kind: "Estimated"` is the instant estimate a property gets on submission; `kind: "Actual"` is a real offer produced by AI comping. - An estimated offer only ever returns its range: `estimatedRange: { low, high }`. `amount` and every terms block are `null`, just as the app shows a range and nothing more. - An actual offer shows its headline `amount` as soon as it exists. Its terms (`cashOffer`, `cashOfferPlus`, `sellNowMoveLater`, `listOnMarket`) stay `null` until you request details and they are shared (`detailsShared: true`), the same gate as the app. - Accepting an estimated offer is how you ask for real offers: it starts AI comping. In the same request you can confirm the home’s details (`attributes`); send no body to skip it. Photos are not sent here: import them first with `POST /openapi/Properties/{id}/photos` and accept once `property.files_imported` arrives, so comping starts with them. - Accepting an actual offer selects it. Its details must be shared first (otherwise 409 or 422). Accept takes no body for an actual offer. - Both actions return the transaction, so you can see the step it moved to. Their optional `If-Match` is the transaction’s ETag, not the offer’s. - request-details asks for the offer’s full terms. It returns the transaction; the terms arrive later with the `offer.details_shared` webhook, then read the offer again. - Steps must happen in order. Accepting or requesting at the wrong step is a 409 that names the step the transaction is on. ### GET /openapi/Offers?propertyId={propertyId}: response right after submission ```json { "items": [ { "id": "9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "transactionId": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "type": "CashOffer", "kind": "Estimated", "status": "Estimated", "disposition": "Available", "currentRevisionId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "isAvailable": true, "isAccepted": false, "isExpired": false, "hasUnacceptedRevision": false, "expiresAt": null, "eTag": "\"0x8DCA…\"", "detailsShared": false, "amount": null, "estimatedRange": { "low": 267750, "high": 362250 }, "cashOffer": null, "cashOfferPlus": null, "sellNowMoveLater": null, "listOnMarket": null } ], "total": 1, "page": 1, "pageSize": 20 } ``` ### GET /openapi/Offers/{id}: an actual offer before details are shared ```json { "id": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "transactionId": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "type": "CashOffer", "kind": "Actual", "status": "Sent", "disposition": "Available", "currentRevisionId": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "isAvailable": true, "isAccepted": false, "isExpired": false, "hasUnacceptedRevision": false, "expiresAt": "2026-10-08T12:00:00Z", "eTag": "\"0x8DCB…\"", "detailsShared": false, "amount": 318000, "estimatedRange": null, "cashOffer": null, "cashOfferPlus": null, "sellNowMoveLater": null, "listOnMarket": null } ``` ### POST /openapi/Offers/{id}/accept: request (estimated offer; body optional) ```json { "attributes": { "bedroomCount": 4, "bathroomCount": 3, "squareFootage": 2450, "yearBuilt": 2016 } } ``` ### POST /openapi/Offers/{id}/accept: 200 OK response (the transaction) ```json { "id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "isActive": true, "stage": "OfferRequested", "detailsDisclosure": "Unshared", "eTag": "\"0x8DCC…\"" } ``` ### GET /openapi/Offers/{id}/history: response ```json { "id": "1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f", "statusHistory": [ { "from": "Finalizing", "to": "Sent", "at": "2026-09-24T14:00:00Z" }, { "from": "Sent", "to": "Accepted", "at": "2026-09-25T09:30:00Z" } ] } ``` ## Transactions and AI comping Read-only. A transaction is one selling journey for a property; follow its stage here or, better, through the `transaction.stage_changed` webhook. | Method | Full URL | Description | | --- | --- | --- | | GET | `https://api.zoodealio.ai/openapi/Transactions/active?propertyId={propertyId}` | The property’s active transaction; 204 when there is none | | GET | `https://api.zoodealio.ai/openapi/Transactions/{transactionId}` | Stage, phase, disclosure state and ETag | | GET | `https://api.zoodealio.ai/openapi/Transactions/{transactionId}/comping` | AI comping status and the reveal link | - There is no endpoint to start AI comping: accepting the estimated offer starts it, exactly like "Calculate my offers" in the app. - Comping `status` is `not_requested`, `pending`, `completed` or `failed`. Responses are never cached. - `revealUrl` opens the offer reveal in the app once comping is complete. It needs a signed-in user with access to the property. Valuation evidence and comparable sales are never returned by the API. - `detailsDisclosure` is `Unshared`, `Shared` or `Revoked`. Offer terms are readable only while it is `Shared`. ### GET /openapi/Transactions/{transactionId}: response ```json { "id": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "isActive": true, "stage": "CompingComplete", "detailsDisclosure": "Unshared", "eTag": "\"0x8DCB…\"" } ``` ### GET /openapi/Transactions/{transactionId}/comping: response ```json { "transactionId": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "status": "completed", "revealUrl": "https:///properties/5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a/offer-reveal" } ``` ## Team The people in your workspace and the teams they belong to. Use a member id as a property’s `agentId`. | Method | Full URL | Description | | --- | --- | --- | | GET | `https://api.zoodealio.ai/openapi/Team/members` | List members | | GET | `https://api.zoodealio.ai/openapi/Team/members/{id}` | Get one member | | POST | `https://api.zoodealio.ai/openapi/Team/members` | Invite a member | | DELETE | `https://api.zoodealio.ai/openapi/Team/members/{id}` | Remove a member, or revoke a pending invite | | GET | `https://api.zoodealio.ai/openapi/Team/teams` | List teams | - `role` is `Owner` (the workspace owner), `AccountAdmin` (manages the whole workspace) or `Member`. Team admins are members who administer one or more teams; `teamIds` lists every team a member is on. - Teams nest: `parentTeamId` points at the team above. A team admin reaches every team below theirs. What you can read and do follows your own role, exactly as in the app. - Inviting returns an invite, not a member: `id` is the invite id and `token` is the link code. The person becomes a member once they accept at `/invite?token=…`. Put them on teams with `teamIds`, and make them a team admin with `adminTeamIds`. - DELETE takes either id: a member id removes the member, an invite id revokes the invite. The workspace owner cannot be removed, and a member who still has homeowners assigned must be reassigned in the app first. - List filters: `search`, `page`, `pageSize`. ### GET /openapi/Team/members: response ```json { "items": [ { "id": "3f6c1a2e-9b4d-4c1f-8a2b-1d2e3f4a5b6c", "fName": "Alex", "lName": "Smith", "email": "alex.smith@example.com", "role": "Member", "teamIds": ["d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a"], "archivedAt": null } ], "page": 1, "pageSize": 20, "totalCount": 1 } ``` ### POST /openapi/Team/members: request ```json { "email": "sam.lee@example.com", "teamIds": ["d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a"], "adminTeamIds": [] } ``` ### 201 Created: response (an invite, not yet a member) ```json { "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "token": "Kt7…", "expiresAt": "2026-10-08T12:00:00Z" } ``` ### GET /openapi/Team/teams: response ```json { "items": [ { "id": "d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a", "name": "Texas", "description": null, "parentTeamId": null, "memberCount": 12 } ], "page": 1, "pageSize": 20, "totalCount": 1 } ``` ## Webhooks Zoodealio calls your HTTPS endpoint at every step of the journey, so you never have to poll. | Method | Full URL | Description | | --- | --- | --- | | GET | `https://api.zoodealio.ai/openapi/Webhooks` | List your subscriptions | | POST | `https://api.zoodealio.ai/openapi/Webhooks` | Create; signing secret shown once | | PUT | `https://api.zoodealio.ai/openapi/Webhooks/{id}` | Replace your subscription settings | | DELETE | `https://api.zoodealio.ai/openapi/Webhooks/{id}` | Delete with its delivery log | | POST | `https://api.zoodealio.ai/openapi/Webhooks/{id}/rotate` | Rotate the signing secret | | GET | `https://api.zoodealio.ai/openapi/Webhooks/event-types` | Current event catalogue | | GET | `https://api.zoodealio.ai/openapi/Webhooks/deliveries` | Your delivery attempts | - `transaction.stage_changed` fires at every step of the journey with `previousStage`, `stage` and `phase`. It is the one event to subscribe to if you want to mirror progress. - The milestones have their own events: `offer.created` (estimate or offers published), `comping.completed` / `comping.failed`, `offer.details_shared` (full terms ready), `offer.accepted`, `offer.declined`, `offer.updated`, `offer.ready`, `offer.countered`, and `property.files_imported` (a photo/document import finished). An empty event list subscribes to all of them. - Payloads carry ids and status, not terms: offer amounts appear in a payload only once details are shared. Read the offer from the API when you need the numbers. - Use a public HTTPS endpoint. Private and local network destinations, URL credentials and redirects are rejected. Set `isActive: false` to pause while retaining the delivery log. - Delivery is at least once: de-duplicate on envelope `id` (also `X-Zoodealio-Delivery`), which stays stable across retries. Access is checked again before every send. - Verify `X-Zoodealio-Signature: t=,v1=` as HMAC-SHA256 using the signing secret and the exact bytes of `.`. Compare in constant time and reject stale timestamps. `X-Zoodealio-Event` gives the event type. - Return a 2xx quickly. Non-2xx, timeouts and connection failures retry with exponential backoff (default six attempts). After exhaustion the delivery is Failed in the log. ### Envelope ```json { "id": "6f1e2d3c-4b5a-4968-8776-5a4b3c2d1e0f", "type": "comping.completed", "apiVersion": "2026-09-22", "occurredAt": "2026-09-22T18:04:11Z", "tenantId": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d", "data": { "…": "event-specific fields" } } ``` ### POST /openapi/Webhooks: request ```json { "endpointUrl": "https://partner.example/zoodealio", "eventTypes": [ "transaction.stage_changed", "property.files_imported", "comping.completed", "comping.failed", "offer.details_shared" ] } ``` ### property.files_imported: data ```json { "importId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "photoIds": ["c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", "…"], "documentIds": ["e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b"], "failed": [ { "source": "https://cdn.example.com/listings/102-shore/kitchen.jpg", "reason": "The URL answered 404." } ] } ``` ### transaction.stage_changed: data ```json { "transactionId": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "propertyId": "5d4c3b2a-1f0e-9d8c-7b6a-5f4e3d2c1b0a", "previousStage": "CompingInProgress", "stage": "CompingComplete", "phase": "OffersReady", "outcome": null, "eTag": "\"0x8DCB…\"" } ``` ## Glossary - **API key**: A personal `zoo_…` key. It acts as you, with your role and access, and is shown once. - **Property**: The core record: an address, the home’s details, its homeowner, photos and documents. Everything else hangs off a property. - **Homeowner**: The person selling. Attach one inline when you submit a property; the property returns their `homeownerId`. - **Estimated offer**: The instant estimate a property gets on submission. The API returns only its range, `estimatedRange: { low, high }`. - **Actual offer**: A real offer produced by AI comping. Its headline `amount` is visible; its terms appear once details are shared. - **AI comping**: Zoodealio’s AI valuation that turns the estimate into real offers. Accepting the estimate starts it. - **Details shared**: Terms stay `null` until you request details and Zoodealio shares them (`detailsShared: true`). - **Transaction**: One selling journey for a property. Its `stage` says which step it is on; every change is a `transaction.stage_changed` webhook. - **Member**: A person in your workspace: `Owner`, `AccountAdmin` or `Member`. A member id is a property’s `agentId`. - **Team**: A group of members. Teams nest; a team admin reaches every team below theirs. - **Invite**: How someone joins. Inviting returns an invite id; they become a member when they accept it. - **Import**: A background download of a property’s photo and document URLs. It returns an `importId`; `property.files_imported` fires when it finishes. - **ETag / If-Match**: Send the ETag from a GET as `If-Match` to reject a write if someone changed the record. - **Webhook**: A signed POST Zoodealio sends to your HTTPS endpoint when something happens, retried until you answer 2xx. ## Questions ### Is there a sandbox or test key? Not yet. Keys act on your real workspace, so test against a workspace you set up for it and use addresses you are happy to price. ### Why is an offer amount null? Either it is an estimate, which only ever shows `estimatedRange`, or it is a real offer whose terms have not been shared yet. Request details, wait for `offer.details_shared`, then read the offer again. ### How do I start AI comping? Accept the property’s estimated offer with `POST /openapi/Offers/{id}/accept`. That is the only way, the same as in the app. ### Do I have to send details and photos when accepting the estimate? No. They make the valuation more accurate, but both are optional. Accept with no body to skip them, or add photos first with `POST /openapi/Properties/{id}/photos` (before accepting, so comping can use them). ### How do I send photos and documents? As URLs you host, e.g. signed links from your storage. Zoodealio imports them in the background: up to 200 files per import, photos up to 10 MB and documents up to 25 MB each. The `property.files_imported` webhook (or the import’s `statusUrl`) lists what was saved and what failed, with the reason. ### Can I create or change an offer? No. Offers are made by Zoodealio. Through the API, as in the app, you accept an offer or request its details. ### Where are clients? On the property. Send the homeowner with the property you submit; there is no separate clients endpoint. ### Can I call the API from a browser? No. The key acts as you, so it must stay on a server. Call Zoodealio from your backend and give your frontend only what it needs. ### Should I poll or use webhooks? Webhooks. Poll only when you must, no more than every 30 seconds, and always honor `Retry-After`. ### Does the API have versions? No. There is one API, every endpoint at `/openapi/`. Webhook payloads carry an `apiVersion` date naming the payload contract they follow.