# DialEcho API overview The DialEcho API lets your own website, backend or tools push leads into your campaigns, keep them up to date, book appointments, attach files, have your AI voice agents call leads, read back what happened to them, read and answer your campaigns' email conversations, and read your email blasts' results. Every endpoint lives under `/v1`, is called with a member API key and is limited to what that member can do in DialEcho right now. ## What the API covers **[Leads](/api/leads/)** - `POST /v1/leads` · [Create or update a lead](/api/leads/create) - `PATCH /v1/leads/{lead_id}` · [Update a lead](/api/leads/update) - `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get) - `PUT /v1/leads/{lead_id}/stage` · [Move a lead's card](/api/leads/move-stage) **[Appointments](/api/appointments/)** - `GET /v1/campaigns/{campaign_id}/appointment-slots` · [List open times](/api/appointments/list-slots) - `PUT /v1/leads/{lead_id}/appointment` · [Book or reschedule](/api/appointments/book) **[Files](/api/files/)** - `POST /v1/leads/{lead_id}/files` · [Upload a file](/api/files/upload) - `DELETE /v1/leads/{lead_id}/files/{file_id}` · [Delete a file](/api/files/delete) **[Calls](/api/calls/)** - `POST /v1/calls` · [Place an AI call](/api/calls/create) **[Reporting](/api/reporting/)** - `GET /v1/campaigns/{campaign_id}/conversions` · [List conversions](/api/reporting/list-conversions) - `GET /v1/leads/{lead_id}/conversion` · [Retrieve a conversion](/api/reporting/get-conversion) - `GET /v1/campaigns/{campaign_id}/pipeline` · [Retrieve the pipeline](/api/reporting/pipeline-board) - `GET /v1/campaigns/{campaign_id}/pipeline/stages/{stage}/leads` · [List a stage's leads](/api/reporting/stage-leads) **[Lead actions](/api/lead-actions/)** - `POST /v1/lead-actions/verify` · [Verify a launch token](/api/lead-actions/verify) **[Inbox](/api/inbox/)** - `GET /v1/inbox/conversations` · [List conversations](/api/inbox/list-conversations) - `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation) - `GET /v1/inbox/inbound-messages` · [List inbound messages](/api/inbox/list-inbound) - `POST /v1/inbox/conversations/{conversation_id}/replies` · [Reply to a conversation](/api/inbox/reply) - `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation) **[Blasts](/api/blasts/)** - `GET /v1/blasts` · [List blasts](/api/blasts/list) - `GET /v1/blasts/{blast_id}` · [Retrieve a blast](/api/blasts/get) - `GET /v1/blasts/{blast_id}/sends` · [List a blast's sends](/api/blasts/list-sends) - `GET /v1/sends` · [Look up sends](/api/blasts/lookup-sends) ## Base URL ```text https://development.agencydevworks.ai ``` All paths are relative to the base URL. Requests and responses are JSON (`Content-Type: application/json`); only file uploads send `multipart/form-data`. Responses carry `Cache-Control: no-store`. Call the API from server code only: a backend service, a serverless function or a framework route handler. ## Authentication Create keys in Settings > API keys: a name, the scopes the integration needs, the campaigns it may reach (or every campaign you can access) and an optional expiry. The secret looks like `dei_<12 hex characters>_<43 characters>` and is shown once. Send it on every request as `Authorization: Bearer `. A key acts as the team member who created it and can never do more than that member can do right now. Access is recomputed on every request: when the member loses a campaign, a permission or their membership, the key loses it too, immediately. Field visibility follows the member's role, so a field the role hides comes back `null` and one it masks comes back masked. A key limited to some campaigns reaches only those, and only while its member still can. A campaign or lead outside the key is reported as `404`, never `403`. Keep the key on your server. Never ship it to a browser or a mobile app; in Next.js store it in a variable without the `NEXT_PUBLIC_` prefix. If it may have leaked, revoke it in Settings > API keys and create a new one. Create and revoke keys in DialEcho: [Settings > API keys](https://app.dialecho.ai/settings#api). **Every request** ```http Authorization: Bearer dei_your_key ``` ### Scopes | Scope | Grants | | --- | --- | | `inbox:read` · Read | Read inbox conversations and messages. | | `inbox:reply` · Reply | Reply to inbox conversations and mark them read or archived. | | `blasts:read` · Blast stats | Read blast stats and look up sends. | | `leads:read` · Lead lookup | Retrieve a lead, read the conversion report and the campaign's pipeline board. | | `leads:write` · Leads | Create and update leads, list open appointment times, book appointments, upload and delete files. | | `pipeline:write` · Pipeline stage | Move a lead's pipeline card to another stage. | | `calls:write` · Calls | Place outbound AI calls with your voice agents. | | No scope | Verify a lead-action token (`POST /v1/lead-actions/verify`): any valid key. | ## Request conventions Bodies are JSON objects. Unknown fields are rejected (`422`, field code `not_allowed`), strings are trimmed before their length is checked (except a reply's `text`), and a body that is not valid JSON is `422` with field `body`, never `401` or `403`, though it spends a request budget like any other request (see Rate limits). Phone numbers can be sent in any common format; DialEcho normalizes them to E.164 (`+14155550132`) and returns E.164. Include the country code for numbers outside the US and Canada. Timestamps are ISO 8601. Timestamps you send must carry a UTC offset (`2026-11-02T09:30:00-05:00` or `2026-11-02T14:30:00Z`), except the inbox and blast filters, which read a time without one as UTC; in a query string URL-encode `+` as `%2B`. Returned timestamps are UTC (`Z`), except appointment times, which are given in the campaign's appointment time zone with its offset. Ids are UUIDs. An id that is not a UUID is `422 invalid_request`; one that does not exist or that the key cannot reach is `404 not_found`. On lead writes, `null`, empty and whitespace-only values mean "not provided" and never clear a stored value. ## Errors Every error uses the same envelope. `code` is stable and machine-readable; `message` is for people and can change, so do not parse it; include `request_id` when you contact support; retry only when `retryable` is `true`. Some errors add keys: `fields` (`invalid_request`), `current_stage` (`stage_conflict`, `member_required`) and `valid_stages` (`unknown_stage`). Unknown paths and methods answer `{"detail": ...}` instead, and an unexpected `500` can come back without the envelope. Every `422 invalid_request` carries `error.fields`, one `{field, code, message}` entry per problem found, not only the first; the inbox and blast filters are the exception, checked one at a time and named without the `query.` prefix (`campaign_id`, `status`, `since`). `field` is the body path (`phone`, `attribution.fbclid`, `custom_fields.roof_age`), `header.Idempotency-Key`, `query.limit`, a path parameter's name (`lead_id`) or `body`. The array is empty when the problem is not in one field, for example an upload that is not one file part, an invalid appointment-slot range or a blast `email` filter that is not an address. The other 422 codes (`unknown_field`, `unknown_stage`, `invalid_cursor`) carry no `fields`. DialEcho keeps every `422 invalid_request` sent with a valid key that has the endpoint's scope, with its body (multipart uploads excepted; of a longer body only its first 32 KB, marked truncated), so support can find a rejected form submission by `request_id` and recover the lead. **Error envelope · 422** ```json { "error": { "code": "invalid_request", "message": "Unknown custom_fields for this campaign: roof_type", "request_id": "12121212-0000-4000-8000-000000000001", "retryable": false, "fields": [ { "field": "custom_fields.roof_type", "code": "not_allowed", "message": "Not a custom field of this campaign" } ] } } ``` ### Field codes | `fields[].code` | Meaning | | --- | --- | | `missing` | A required field was not sent. | | `not_allowed` | The field is not accepted here: an unknown body or `attribution` key, or a `custom_fields` key that is not one of the campaign's fields (or is a file or image field). | | `too_long` | Longer than its limit (characters, or entries for `custom_fields`). | | `too_short` | Shorter than its minimum, for example an `Idempotency-Key` under 16 characters. | | `phone_invalid` | The phone number cannot exist. | | `email_invalid` | Not an email address that can receive mail (including one over 254 characters). | | `invalid` | Any other bad value: a wrong type, a value that is not a UUID or a timestamp, and so on. Treat a code you do not know like `invalid`. | ### Status codes | HTTP | Code | When | | --- | --- | --- | | 401 | `invalid_api_key` | Missing, malformed, unknown, revoked or expired key, a member who is no longer active, or a suspended organization. Check the key. | | 403 | `insufficient_scope` | The key does not have the endpoint's scope. Checked before the request is validated. | | 403 | `permission_denied` | The key's member lacks a DialEcho permission the endpoint needs: managing the campaign's leads (create, update, and calls by `phone`), creating appointments (booking), seeing files (uploads and deletes) or placing manual calls (calls). Updates, bookings, uploads and deletes answer `404` for a lead the member cannot see before checking the permission, so a `403` never reveals a hidden lead. | | 404 | `not_found` | The campaign, lead, file, agent, pipeline, conversation, message or blast does not exist or the key cannot reach it (another organization, outside the key's campaigns, or a lead or conversation its member cannot see). The two cases are deliberately indistinguishable. | | 404 | `invalid_token` | Lead actions: the token is not valid for this key. | | 409 | `idempotency_conflict` | The `Idempotency-Key` was already used for a different request. Nothing was written or sent. | | 409 | `no_pipeline_card` | Leads and files: the lead has no pipeline card to hold the custom fields, deal value or file, or to move. | | 409 | `stage_conflict` | Stage moves: the card is not in `expected_stage`. `error.current_stage` is its stage. | | 409 | `member_required` | Stage moves: only a team member may make this move. `error.current_stage` is its stage. | | 409 | `slot_unavailable` | Appointments: the time is not open (any more). Re-fetch the slots. | | 409 | `appointment_not_movable` | Appointments: the lead's current appointment has started or been handled and is never moved. | | 409 | `availability_not_configured` | Appointments: the campaign has no bookable hours. Retrying unchanged will not help. | | 409 | `no_hosts_configured` | Appointments: no team member can take appointments in the campaign. | | 409 | `do_not_call` | Calls: the number is on the do-not-call list. Do not retry. | | 409 | `invalid_phone` | Calls: the lead's number cannot be called. Do not retry. | | 409 | `not_callable` | Calls: outside calling hours with no later calling window. | | 409 | `conversation_changed` | Replies: a newer inbound message arrived. Read the conversation again and answer the latest. | | 409 | `already_replied` | Replies: the message was already answered by your team, the AI agent or an API reply. Do not resend. | | 409 | `reply_in_progress` | Replies and archiving: another reply to the conversation is being sent. `retryable` is `true`: retry shortly with the same request. | | 409 | `delivery_unknown` | Replies: the reply may have been sent. Retry only with the same `Idempotency-Key` and body, never as a new request. | | 409 | `conversation_archived` | Replies: the conversation is archived. Restore it first. | | 409 | `recipient_suppressed` | Replies: the recipient unsubscribed or is suppressed. Do not retry. | | 409 | `non_actionable_inbound` | Replies: the message cannot be answered automatically (an auto-reply or opt-out, or no recipient address). | | 409 | `sender_unavailable` | Replies: the `send_as` sender is not set up, disconnected or out of capacity. Nothing was sent. | | 409 | `draft_requires_review` | Replies on real-estate conversations: an AI draft for the message is waiting for review in DialEcho. | | 409 | `resync_required` | Paginated lists: the cursor expired or access changed. Start again from the first page. | | 413 | `file_too_large` | Uploads: the file is larger than 10 MB. | | 413 | `payload_too_large` | A JSON body is larger than 1 MB. It was not read and nothing was stored. | | 415 | `unsupported_media_type` | Uploads: the file is not a PDF, JPEG, PNG, WebP, HEIC or HEIF file, or is a PDF sent to an image-only field. | | 422 | `invalid_request` | The body, a path or query parameter, or a header is invalid. `error.fields` lists every problem. Fix the request; do not retry it unchanged. | | 422 | `unknown_field` | Uploads: `field` is not a file or image custom field of the lead's campaign. | | 422 | `unknown_stage` | Stage moves and stage lists: the stage is not in the pipeline. `error.valid_stages` lists its slugs. | | 422 | `invalid_cursor` | Paginated lists: the cursor is not valid for this request. | | 429 | `rate_limited` | Too many requests. Wait the `Retry-After` seconds, then retry with jitter. | | 502 | `dependency_unavailable` | File deletes: the stored file could not be removed and stays on the card. Retry with backoff. | | 503 | `dependency_unavailable` | Temporarily unable to verify the key, store a file, check the do-not-call list, calling hours or email suppression, or read the report, board, inbox or blasts. Retry with backoff; retry uploads, calls and replies with the same `Idempotency-Key`. | ## Rate limits Limits are token buckets: a burst can be spent at once and refills at the per-minute rate. Every endpoint draws from the same per-key request budget, whatever scopes the key has; AI calls and email replies also draw from their own. A body that is not valid JSON is admitted like any other request: it spends the key's request budget (with a missing or invalid key, the client IP's invalid-key budget) and is `429 rate_limited` once that budget is spent. A limited request gets `429 rate_limited` with `retryable: true` and an integer `Retry-After` header in seconds (rounded up, at least 1). Wait that long, then retry with jitter. **429 response header** ```http Retry-After: 3 ``` ### Budgets | Limit | Budget | | --- | --- | | Requests per API key | 120 per minute, bursts of up to 30. | | AI calls (`POST /v1/calls`) per API key, on top of the request limit | 20 per minute, bursts of up to 5. Every call request counts, replays and refused calls included. | | Email replies (`POST /v1/inbox/conversations/{conversation_id}/replies`) per API key, on top of the request limit | 20 per minute, bursts of up to 5. Every reply request that passes validation counts, replays and refused replies included. | | Requests per organization: every API key and the DialEcho app together | 1,200 per minute, bursts of up to 1,200 (the default). | | Requests with a missing or invalid key, per client IP | 60 per minute, bursts of up to 10. While it is exhausted every request from that IP gets `429`, even one with a valid key. | ## Pagination Lists (the conversion report, a stage's leads, inbox conversations and inbound messages, blasts and sends) return `data`, `next_cursor` and `has_more`. Request the next page with `cursor` set to `next_cursor` until `has_more` is `false` and `next_cursor` is `null`. Two inbox lists differ: the inbound message feed always returns a `next_cursor` to poll with next, and a conversation's messages page with `messages_cursor` and `next_messages_cursor`. Each request looks at a bounded number of leads, conversations or messages and skips the ones the key cannot see (or that do not match `milestone`), so a page can hold fewer rows than `limit`, or none, while `has_more` is still `true`. Keep following `next_cursor`. A cursor is opaque: pass it back unchanged, alone or with exactly the parameters of the page it came from (the blast send lists need their `email` every time). A changed cursor, or one sent with different filters, another campaign, stage or pipeline, or another API key, is `422 invalid_cursor`. A cursor is valid for 30 days; an expired one, or one issued before the key's or its member's access changed, is `409 resync_required`: start again from the first page. **A page** ```json { "data": [], "next_cursor": "v2.exampleOpaqueCursor", "has_more": true } ``` ## Idempotency The header is 16 to 128 printable ASCII characters with no spaces; a UUID works. Endpoints not listed ignore it. Keys are private to the API key that sent them, and each endpoint keeps its own (a reply's key also belongs to its conversation), so one value can name a lead, an upload and a call independently. Reusing a key for a different request is `409 idempotency_conflict` and changes nothing. Requests are compared after validation, so a different phone spelling or JSON key order is still the same request. Only an accepted request uses up its key: a request refused with an error wrote nothing, so retry it with the same key. **Header** ```http Idempotency-Key: 0f0f0f0f-0000-4000-8000-00000000abcd ``` ### Endpoints | Endpoint | `Idempotency-Key` | A retry with the same key and request | | --- | --- | --- | | `POST /v1/leads` | Optional | Returns the first answer (`201` stays `201`) and writes nothing. `404` once the lead is gone or the key's member can no longer see it, as for a new request; if they can no longer open the card the first answer named, `pipeline_lead_id` is the card a new request would name. Without a key a retry still merges into the same lead by phone. | | `POST /v1/leads/{lead_id}/files` | Required | Returns `201` with the same `file_id` and stores nothing new. | | `POST /v1/calls` | Required | Returns `202` with the same `call_id` and the call's current `status`; never dials again. `404` once the key's member can no longer see the lead, as for a new request. | | `POST /v1/inbox/conversations/{conversation_id}/replies` | Required | Returns `200` with the original message and `replayed: true`; never sends again. After `409 delivery_unknown` it reports the outcome once it is known. | | `PUT /v1/leads/{lead_id}/appointment`, `PUT /v1/leads/{lead_id}/stage` | Not used | Idempotent by design: the `start` the lead already holds answers `unchanged`, a card already in `stage` answers `changed: false`. | --- # Leads A lead is one person in one campaign, identified by their phone number. Push leads from your website or backend, merge in more details as the visitor tells you more, read them back and move their pipeline card. Writes merge, so a multi-step form can send its whole state on every step: `null`, empty and whitespace-only values mean "not provided" and never clear a stored value, a non-empty value replaces the stored one, and `custom_fields` and `attribution` merge key by key (stored keys you do not send are kept). Write responses carry identifiers only and never echo personal data; read a lead back with `GET /v1/leads/{lead_id}`. ## Endpoints ### [Create or update a lead](/api/leads/create) `POST /v1/leads` Creates a lead in a campaign, or merges into the campaign's existing lead with the same phone number. ### [Update a lead](/api/leads/update) `PATCH /v1/leads/{lead_id}` Merges more details into a lead, typically from later steps of a form. ### [Retrieve a lead](/api/leads/get) `GET /v1/leads/{lead_id}` Returns a lead's details and its pipeline card as it is on the board now. ### [Move a lead's card](/api/leads/move-stage) `PUT /v1/leads/{lead_id}/stage` Moves the lead's pipeline card to another stage of its pipeline, the way a move on the board does. --- # Create or update a lead `POST /v1/leads` Creates a lead in a campaign, or merges into the campaign's existing lead with the same phone number. **Scope:** `leads:write` **Access:** The key's member must be allowed to manage leads in the campaign. Phone-first upsert. Within a campaign the phone number, normalized to E.164, identifies the lead: posting a number the campaign already has never creates a duplicate. The new values merge into that lead and the answer is `200` with `created: false`; a new lead is `201` with `created: true`. Merging needs a lead the key's member can see in DialEcho, otherwise `404` and nothing is written. The lead is linked to a CRM contact (created from the phone number when the organization has none yet). If it is not on the campaign's board yet, its card is placed in the first stage of the campaign's default pipeline (created with default stages if the campaign has none) and routed to a rep by the campaign's assignment rules. A lead that already has a card is never moved by `POST` or `PATCH`; your team's placement wins. Once the lead has a card, the campaign's automated outreach for it stops. `custom_fields` are saved on that pipeline card, in its Custom Fields panel. If the lead has no card and none can be placed, a request with non-blank `custom_fields` fails with `409 no_pipeline_card`; the lead and its other details are still saved, so fix the campaign's pipeline and send the same request again. For a member who may open only a follow-through card of the lead, `pipeline_lead_id` is that card and the `custom_fields` are saved on the deal it shares with the main card. The lead's time zone is set when it is created: from its state, else a state found in the address, else the ZIP in `postal_code`, else the phone's area code. A later `POST` or `PATCH` can fill a missing state or time zone but never changes one the lead already has. ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | No | A retry with the same key and body returns the first answer (`201` stays `201`) and writes nothing. It is `404 not_found` once the lead is gone or the key's member can no longer see it, as for a new request; if they can no longer open the card the first answer named, `pipeline_lead_id` is the card a new request would name. Reusing the key with a different body is `409 idempotency_conflict`. Even without a key, a retry merges into the same lead by phone. 16 to 128 printable ASCII characters, no spaces (a UUID works). | ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | Yes | A campaign the key may write to. | | `phone` | `string` | Yes | The lead's phone number, normalized to E.164: `(415) 555-0132`, `415.555.0132` and `+1 415 555 0132` all become `+14155550132`. Digits and a leading `+` are kept and everything else is dropped. 10 digits, or 11 starting with `1`, are read as US/Canada; any other number must carry its country code. Do not send extensions: their digits become part of the number. A number of impossible length is rejected with field code `phone_invalid`. Up to 40 characters. | | `first_name` | `string` | No | First name. Up to 100 characters. | | `last_name` | `string` | No | Last name. Up to 100 characters. | | `email` | `string` | No | An email address that can receive mail. Anything else, including an address over 254 characters, is rejected with field code `email_invalid`. Up to 254 characters. | | `address` | `string` | No | Street address. Up to 500 characters. | | `city` | `string` | No | City. Up to 100 characters. | | `state` | `string` | No | A US state name or 2-letter code; sets the lead's time zone when it has none. Up to 100 characters. | | `postal_code` | `string` | No | A US ZIP sets the lead's state (and its time zone, if it has none) when neither `state` nor `address` names one and none is stored. Up to 20 characters. | | `company` | `string` | No | Company name. Up to 200 characters. | | `notes` | `string` | No | Free-text notes on the lead. Up to 5,000 characters. | | `custom_fields` | `object` | No | Values for the campaign's own custom fields, keyed by field slug. Each value is a string, number, boolean or `null`. Saved on the lead's pipeline card as text, exactly as if your team typed it: `true`/`false` for checkbox fields, numbers as sent (`12` becomes `"12"`), strings trimmed. A slug that is not one of the campaign's fields, or is a file or image field, is reported as `custom_fields.` with `not_allowed` (upload files with `POST /v1/leads/{lead_id}/files`); a bad value (another type, or a string too long) is reported once, as `custom_fields`. Up to 100 entries. Keys 1 to 100 characters; string values up to 2,000 characters. | | `attribution` | `object` | No | Marketing attribution, stored on the lead under the same key names and returned by the conversion report for ad-platform matching. Any key not listed here is rejected with `not_allowed`. | | `attribution.utm_source` | `string` | No | UTM source of the visit. Up to 500 characters. | | `attribution.utm_medium` | `string` | No | UTM medium. Up to 500 characters. | | `attribution.utm_campaign` | `string` | No | UTM campaign. Up to 500 characters. | | `attribution.utm_term` | `string` | No | UTM term. Up to 500 characters. | | `attribution.utm_content` | `string` | No | UTM content. Up to 500 characters. | | `attribution.utm_id` | `string` | No | UTM id, for example the Meta campaign id. Up to 500 characters. | | `attribution.adset_id` | `string` | No | Ad set id of the ad the visitor came from. Up to 500 characters. | | `attribution.ad_id` | `string` | No | Ad id of the ad the visitor came from. Up to 500 characters. | | `attribution.fbclid` | `string` | No | Meta's click id from the landing page URL. Up to 500 characters. | | `attribution.fbp` | `string` | No | The visitor's Meta `_fbp` cookie. Up to 500 characters. | | `attribution.fbc` | `string` | No | The visitor's Meta `_fbc` cookie. Up to 500 characters. | | `attribution.gclid` | `string` | No | Google's click id. Up to 500 characters. | | `attribution.meta_event_id` | `string` | No | The event ID your Meta Pixel's browser `Lead` event used for this submission, so a `Lead` sent from the conversion report deduplicates against it. Up to 200 characters. | | `attribution.page_url` | `string` | No | The page the form was submitted on. Up to 2,048 characters. | | `attribution.form_name` | `string` | No | Your name for the form or form step. Up to 200 characters. | | `attribution.referrer` | `string` | No | The referring page. Up to 2,048 characters. | ## Response **Status:** `201 Created · 200 OK` `201` for a new lead; `200` when the campaign already had this phone number and the values were merged. The identifiers are the same on every resubmission. | Name | Type | Description | | --- | --- | --- | | `lead_id` | `UUID` | The lead. | | `contact_id` | `UUID \| null` | The CRM contact the lead is linked to. | | `pipeline_lead_id` | `UUID \| null` | The lead's card on the campaign's board, the card `GET /v1/leads/{lead_id}` returns as `card` (for a member who may open only a follow-through card of the lead, that card). `null` only when the lead could not be placed (for example the first stage only accepts cards placed by a person); sending the same `POST` again retries the placement. | | `created` | `boolean` | `true` when this request created the lead. | ## Example request **curl** ```bash curl -X POST "https://development.agencydevworks.ai/v1/leads" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 0f0f0f0f-0000-4000-8000-00000000abcd" \ -d '{ "campaign_id": "cccccccc-0000-4000-8000-000000000001", "phone": "(415) 555-0132", "first_name": "Pat", "email": "pat@example.com", "state": "CA", "custom_fields": { "roof_age": 12 }, "attribution": { "utm_source": "facebook", "utm_medium": "paid_social", "utm_campaign": "fall-promo", "fbclid": "IwAR0exampleClickId", "fbp": "fb.1.1791900000000.1234567890", "page_url": "https://www.example.com/get-a-quote", "form_name": "quote-step-1" } }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/leads", { method: "POST", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", "Idempotency-Key": "0f0f0f0f-0000-4000-8000-00000000abcd", }, body: JSON.stringify({ "campaign_id": "cccccccc-0000-4000-8000-000000000001", "phone": "(415) 555-0132", "first_name": "Pat", "email": "pat@example.com", "state": "CA", "custom_fields": { "roof_age": 12 }, "attribution": { "utm_source": "facebook", "utm_medium": "paid_social", "utm_campaign": "fall-promo", "fbclid": "IwAR0exampleClickId", "fbp": "fb.1.1791900000000.1234567890", "page_url": "https://www.example.com/get-a-quote", "form_name": "quote-step-1" } }), }); const data = await response.json(); ``` ## Example response **Status:** `201 Created` ```json { "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "contact_id": "bbbbbbbb-0000-4000-8000-000000000001", "pipeline_lead_id": "dddddddd-0000-4000-8000-000000000001", "created": true } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:write`. Checked before the body is validated. | | 403 | `permission_denied` | The key's member may not manage leads in this campaign. | | 404 | `not_found` | The campaign does not exist or the key cannot reach it, or the phone matches a lead the key's member cannot see. | | 409 | `idempotency_conflict` | The `Idempotency-Key` was already used with a different body. Nothing was written. | | 409 | `no_pipeline_card` | Non-blank `custom_fields` were sent but the lead has no card and none could be placed. The lead and its other details were saved; the custom fields were not. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was written. | | 422 | `invalid_request` | The body or a header is invalid: unknown or missing fields, values too long, an invalid phone or email, unknown or file/image custom fields, a `deal_value`, or a malformed `Idempotency-Key`. `error.fields` lists every problem. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `PATCH /v1/leads/{lead_id}` · [Update a lead](/api/leads/update) - `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get) - `PUT /v1/leads/{lead_id}/stage` · [Move a lead's card](/api/leads/move-stage) - `POST /v1/leads/{lead_id}/files` · [Upload a file](/api/files/upload) - `POST /v1/calls` · [Place an AI call](/api/calls/create) --- # Update a lead `PATCH /v1/leads/{lead_id}` Merges more details into a lead, typically from later steps of a form. **Scope:** `leads:write` **Access:** The key's member must be allowed to manage leads in the lead's campaign. Takes the same optional details as `POST /v1/leads`, with the same limits and merge rules, plus `deal_value`. Custom field slugs are checked against the lead's campaign. `campaign_id` and `phone` cannot be sent: a lead's phone number is its identity, so to capture a different number `POST` a new lead. `PATCH` never creates or moves the lead's pipeline card and never changes a time zone the lead already has. `custom_fields` and `deal_value` are saved on the card the lead already has; if it has none, a `PATCH` carrying either fails with `409 no_pipeline_card` and writes nothing. For a member who may open only a follow-through card of the lead, they are saved on the deal that card shares with the main card, and `pipeline_lead_id` is the follow-through card. The lead must belong to a campaign the key may write to and be one the key's member can see in DialEcho, otherwise `404`, checked before the member's permission to manage leads. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lead_id` | `UUID` | Yes | The lead, as `POST /v1/leads` returned it. | ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `first_name` | `string` | No | First name. Up to 100 characters. | | `last_name` | `string` | No | Last name. Up to 100 characters. | | `email` | `string` | No | An email address that can receive mail. Anything else, including an address over 254 characters, is rejected with field code `email_invalid`. Up to 254 characters. | | `address` | `string` | No | Street address. Up to 500 characters. | | `city` | `string` | No | City. Up to 100 characters. | | `state` | `string` | No | A US state name or 2-letter code; sets the lead's time zone when it has none. Up to 100 characters. | | `postal_code` | `string` | No | A US ZIP sets the lead's state (and its time zone, if it has none) when neither `state` nor `address` names one and none is stored. Up to 20 characters. | | `company` | `string` | No | Company name. Up to 200 characters. | | `notes` | `string` | No | Free-text notes on the lead. Up to 5,000 characters. | | `custom_fields` | `object` | No | Values for the campaign's own custom fields, keyed by field slug. Each value is a string, number, boolean or `null`. Saved on the lead's pipeline card as text, exactly as if your team typed it: `true`/`false` for checkbox fields, numbers as sent (`12` becomes `"12"`), strings trimmed. A slug that is not one of the campaign's fields, or is a file or image field, is reported as `custom_fields.` with `not_allowed` (upload files with `POST /v1/leads/{lead_id}/files`); a bad value (another type, or a string too long) is reported once, as `custom_fields`. Up to 100 entries. Keys 1 to 100 characters; string values up to 2,000 characters. | | `attribution` | `object` | No | Marketing attribution, stored on the lead under the same key names and returned by the conversion report for ad-platform matching. Any key not listed here is rejected with `not_allowed`. | | `attribution.utm_source` | `string` | No | UTM source of the visit. Up to 500 characters. | | `attribution.utm_medium` | `string` | No | UTM medium. Up to 500 characters. | | `attribution.utm_campaign` | `string` | No | UTM campaign. Up to 500 characters. | | `attribution.utm_term` | `string` | No | UTM term. Up to 500 characters. | | `attribution.utm_content` | `string` | No | UTM content. Up to 500 characters. | | `attribution.utm_id` | `string` | No | UTM id, for example the Meta campaign id. Up to 500 characters. | | `attribution.adset_id` | `string` | No | Ad set id of the ad the visitor came from. Up to 500 characters. | | `attribution.ad_id` | `string` | No | Ad id of the ad the visitor came from. Up to 500 characters. | | `attribution.fbclid` | `string` | No | Meta's click id from the landing page URL. Up to 500 characters. | | `attribution.fbp` | `string` | No | The visitor's Meta `_fbp` cookie. Up to 500 characters. | | `attribution.fbc` | `string` | No | The visitor's Meta `_fbc` cookie. Up to 500 characters. | | `attribution.gclid` | `string` | No | Google's click id. Up to 500 characters. | | `attribution.meta_event_id` | `string` | No | The event ID your Meta Pixel's browser `Lead` event used for this submission, so a `Lead` sent from the conversion report deduplicates against it. Up to 200 characters. | | `attribution.page_url` | `string` | No | The page the form was submitted on. Up to 2,048 characters. | | `attribution.form_name` | `string` | No | Your name for the form or form step. Up to 200 characters. | | `attribution.referrer` | `string` | No | The referring page. Up to 2,048 characters. | | `deal_value` | `number` | No | The deal value of the lead's pipeline card, as your team sees it on the card. `0` is a value and is saved; `null` or leaving it out keeps the stored value. The number is checked exactly as written in your JSON, never rounded first, so `100000000.000000001` is rejected and `12.500` is saved as `12.5`. Read it back as `card.deal_value` from `GET /v1/leads/{lead_id}`. 0 to 100,000,000 with at most 2 decimal places. Strings and booleans are rejected. | ## Response **Status:** `200 OK` Identifiers only. | Name | Type | Description | | --- | --- | --- | | `lead_id` | `UUID` | The lead. | | `contact_id` | `UUID \| null` | The CRM contact the lead is linked to. | | `pipeline_lead_id` | `UUID \| null` | The lead's card on the campaign's board, the card `GET /v1/leads/{lead_id}` returns as `card` (for a member who may open only a follow-through card of the lead, that card). `null` only when the lead could not be placed (for example the first stage only accepts cards placed by a person); sending the same `POST` again retries the placement. | ## Example request **curl** ```bash curl -X PATCH "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -d '{ "last_name": "Lee", "address": "1 Market St", "city": "San Francisco", "postal_code": "94105", "custom_fields": { "roof_age": 12 }, "attribution": { "form_name": "quote-step-2" }, "deal_value": 12500.5 }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001", { method: "PATCH", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ "last_name": "Lee", "address": "1 Market St", "city": "San Francisco", "postal_code": "94105", "custom_fields": { "roof_age": 12 }, "attribution": { "form_name": "quote-step-2" }, "deal_value": 12500.5 }), }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "contact_id": "bbbbbbbb-0000-4000-8000-000000000001", "pipeline_lead_id": "dddddddd-0000-4000-8000-000000000001" } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:write`. Checked before the body is validated. | | 403 | `permission_denied` | The key's member may not manage leads in the lead's campaign. Only for a lead they can see; any other lead is `404`. | | 404 | `not_found` | The lead does not exist or the key cannot reach it (other organization, a campaign outside the key, or a lead its member cannot see). | | 409 | `no_pipeline_card` | `custom_fields` or `deal_value` were sent but the lead has no pipeline card (or it was deleted while the request ran). Nothing was written. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was written. | | 422 | `invalid_request` | `lead_id` is not a UUID, or the body is invalid: `campaign_id` or `phone` sent, unknown fields, values too long, an invalid email, unknown or file/image custom fields, or a `deal_value` outside its rules. `error.fields` lists every problem. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `POST /v1/leads` · [Create or update a lead](/api/leads/create) - `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get) --- # Retrieve a lead `GET /v1/leads/{lead_id}` Returns a lead's details and its pipeline card as it is on the board now. **Scope:** `leads:read` Details are read from where `POST` and `PATCH` store them. The card is the one the key's member may open: the lead's main (sales) card, or, for a member whose role limits the pipelines or assigned records they see and who may open only a follow-through card of the lead (for example an installer card on another team's pipeline), that card, with its own `id`, `pipeline_id`, `stage` and `assigned_member_id`. `deal_value` and `custom_fields` come from the deal it shares with the main card, the deal your `custom_fields` writes and file uploads go to. Nothing of a card the member may not open is returned. The lead must belong to a campaign the key may reach and be one the key's member can see in DialEcho, otherwise `404`. Reading does not need the member's permission to manage leads. The key sees the lead the way its member sees it in DialEcho. A contact field the member's role hides is `null`, and one it masks comes back masked (`***-***-0132`, `p***@example.com` or `Restricted`): `phone`, `email`, `first_name` and `last_name` (the name field), and `address`, `city`, `state` and `postal_code` (the address field). `company` is always shown. Roles without contact-field settings hide phone, email and custom fields; admins see every field. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lead_id` | `UUID` | Yes | The lead, as `POST /v1/leads` returned it. | ## Response **Status:** `200 OK` `notes` and `attribution` are not returned; the conversion report returns attribution. | Name | Type | Description | | --- | --- | --- | | `id` | `UUID` | The lead. | | `campaign_id` | `UUID` | The lead's campaign. | | `phone` | `string \| null` | The phone number as stored: E.164 (`+14155550132`) for leads created through this API. `null` for a lead without a phone number. | | `first_name` | `string \| null` | As stored; `null` when not set. | | `last_name` | `string \| null` | As stored; `null` when not set. | | `email` | `string \| null` | As stored; `null` when not set. | | `address` | `string \| null` | As stored; `null` when not set. | | `city` | `string \| null` | As stored; `null` when not set. | | `state` | `string \| null` | As stored; `null` when not set. | | `postal_code` | `string \| null` | As stored; `null` when not set. | | `company` | `string \| null` | As stored; `null` when not set. | | `card` | `object \| null` | The card the key's member may open, as described above; `null` only when the lead has no card the member can open. | | `card.id` | `UUID` | The card (`pipeline_lead_id` in write responses). | | `card.pipeline_id` | `UUID \| null` | The card's pipeline. | | `card.stage` | `string \| null` | Slug of its current stage, as stage moves use it. | | `card.deal_value` | `number \| null` | The deal value, shared with the main card, or `null`. | | `card.assigned_member_id` | `UUID \| null` | The team member the card is assigned to, or `null`. | | `card.custom_fields` | `object \| null` | The deal's custom field values (shared with the main card) by slug, as the text DialEcho stores. Empty fields, file and image fields and deleted fields are left out. `null` when the member's role hides custom fields; every value is `Restricted` when it masks them. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "id": "aaaaaaaa-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "phone": "+14155550132", "first_name": "Pat", "last_name": "Lee", "email": "pat@example.com", "address": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "company": null, "card": { "id": "dddddddd-0000-4000-8000-000000000001", "pipeline_id": "eeeeeeee-0000-4000-8000-000000000001", "stage": "new", "deal_value": 12500.5, "assigned_member_id": "ffffffff-0000-4000-8000-000000000001", "custom_fields": { "roof_age": "12" } } } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:read`. | | 404 | `not_found` | The lead does not exist or the key cannot reach it (other organization, a campaign outside the key, or a lead its member cannot see). | | 422 | `invalid_request` | `lead_id` is not a UUID. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `PATCH /v1/leads/{lead_id}` · [Update a lead](/api/leads/update) - `GET /v1/leads/{lead_id}/conversion` · [Retrieve a conversion](/api/reporting/get-conversion) - `PUT /v1/leads/{lead_id}/stage` · [Move a lead's card](/api/leads/move-stage) --- # Move a lead's card `PUT /v1/leads/{lead_id}/stage` Moves the lead's pipeline card to another stage of its pipeline, the way a move on the board does. **Scope:** `pipeline:write` The card is the one `GET /v1/leads/{lead_id}` returns as `card`: for a member who may open only a follow-through card of the lead, that card, never the main card. `stage`, `expected_stage`, `previous_stage`, `error.current_stage` and `error.valid_stages` all refer to it. The move is recorded in the card's stage history and the pipeline's stage automations run exactly as when your team moves the card. A card already in `stage` answers `200` with `changed: false` whatever `expected_stage` says, so retrying after a timeout is safe. Compare and set: send `expected_stage` with every automated move. The check and the move are one atomic write, so if someone moved the card since you read it, nothing changes and you get `409 stage_conflict` with the card's `current_stage`. Read the lead again and decide again; do not resend the same move blindly. Human placement wins. A move not made for an active team member (no `acting_member_id`, or one that is not an active member) never overrides a person: if the card was last placed by a team member, `stage` is one only a person may place cards in (the AI follow-up stage, `ai-follow-up`), or the card is a follow-through card (only a person moves those), the request fails with `409 member_required` and nothing changes. The lead must belong to a campaign the key may reach and be one the key's member can see in DialEcho, otherwise `404`. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lead_id` | `UUID` | Yes | The lead, as `POST /v1/leads` returned it. | ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `stage` | `string` | Yes | Slug of the stage to move the card to, as `card.stage` and the pipeline board show slugs. It must be a stage of the card's pipeline. 1 to 200 characters after trimming. | | `expected_stage` | `string` | No | Slug of the stage you believe the card is in now. If it is in any other stage nothing changes and the request fails with `409 stage_conflict`. Must not be blank. 1 to 200 characters after trimming. | | `acting_member_id` | `UUID` | No | The team member you are moving the card for, for example the `member.id` a verified lead-action token returned. If it is an active member of your organization the move is recorded as theirs, made through the API, exactly as if they had moved the card on the board; otherwise it is recorded as a move by the API. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `lead_id` | `UUID` | The lead. | | `pipeline_lead_id` | `UUID` | The card that was moved. | | `stage` | `string` | The card's stage now (the `stage` you sent). | | `previous_stage` | `string` | The stage the card was moved from; equals `stage` when nothing changed. | | `changed` | `boolean` | `false` when the card was already in `stage`: nothing was recorded and no automation ran. | ## Example request **curl** ```bash curl -X PUT "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/stage" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -d '{ "stage": "won", "expected_stage": "appointment", "acting_member_id": "ffffffff-0000-4000-8000-000000000001" }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/stage", { method: "PUT", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ "stage": "won", "expected_stage": "appointment", "acting_member_id": "ffffffff-0000-4000-8000-000000000001" }), }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "pipeline_lead_id": "dddddddd-0000-4000-8000-000000000001", "stage": "won", "previous_stage": "appointment", "changed": true } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `pipeline:write`. Checked before the body is validated. | | 404 | `not_found` | The lead does not exist or the key cannot reach it. | | 409 | `no_pipeline_card` | The lead has no pipeline card. Nothing was moved. | | 409 | `stage_conflict` | The card is not in `expected_stage` (or moved while the request ran). `error.current_stage` is its stage now. Nothing was moved. | | 409 | `member_required` | A move not made for an active team member was refused because a person placed the card, only a person may place cards in `stage`, or the card is a follow-through card. `error.current_stage` is its stage. Nothing was moved. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was written. | | 422 | `unknown_stage` | `stage` is not a stage of the card's pipeline. `error.valid_stages` lists that pipeline's slugs in board order. | | 422 | `invalid_request` | `lead_id` is not a UUID, or the body is invalid: `stage` missing or blank, a blank `expected_stage`, an `acting_member_id` that is not a UUID, or an unknown field. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get) - `GET /v1/campaigns/{campaign_id}/pipeline` · [Retrieve the pipeline](/api/reporting/pipeline-board) - `POST /v1/lead-actions/verify` · [Verify a launch token](/api/lead-actions/verify) --- # Appointments Show a calendar of the times a campaign can take right now, then book the visitor's pick on their lead. Bookings go through the same atomic check DialEcho's AI uses when it books on a call, so your site and the AI never double-book a team member. Bookable hours come from the campaign's Appointment availability settings in DialEcho; a campaign with availability off or no hours answers `409 availability_not_configured`. ## Endpoints ### [List open times](/api/appointments/list-slots) `GET /v1/campaigns/{campaign_id}/appointment-slots` Lists the appointment start times that can be booked in a campaign right now. ### [Book or reschedule](/api/appointments/book) `PUT /v1/leads/{lead_id}/appointment` Books the lead's appointment at an open time, or moves its existing appointment there. --- # List open times `GET /v1/campaigns/{campaign_id}/appointment-slots` Lists the appointment start times that can be booked in a campaign right now. **Scope:** `leads:write` No lead is needed. A start time is listed only when it is inside the campaign's appointment hours (every `slot_minutes` from each opening time, with the whole slot ending by closing time), at least 30 minutes from now, and at least one appointment host has room at that exact time. Hosts are the team members in the campaign's routing with an assignment weight above 0%; a host has room while they have fewer appointments starting then, across every campaign of your organization, than the campaign's Max bookings per time slot. Local times skipped by a daylight-saving change are never listed. Fetch the slots from your server and cache the response there for about 60 seconds, so visitors share one request per minute. A listed time can still be taken before the visitor books it; the booking then answers `409 slot_unavailable`. Re-fetch, bypassing your cache, and let the visitor pick again. URL-encode the timestamps: a literal `+` in a query string means a space, so send `+00:00` as `%2B00:00` (`URLSearchParams` does this) or use `Z`. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | Yes | A campaign the key may reach. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `start` | `timestamp` | No | Earliest slot start to list (inclusive). ISO 8601 with a UTC offset, for example `2026-11-02T09:00:00-05:00` or `2026-11-02T14:00:00Z`. Default: now. | | `end` | `timestamp` | No | List slots starting before this time. ISO 8601 with a UTC offset. After `start` and at most 31 days after it. Default: `start` + 14 days. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `campaign_id` | `UUID` | The campaign. | | `timezone` | `string` | IANA time zone of the campaign's appointment hours. Every `start` is given in it, with its UTC offset. | | `slot_minutes` | `integer` | The grid and the length of a slot. | | `slots` | `array of objects` | Only open start times, ascending. Empty when nothing is open in the range. | | `slots.start` | `timestamp` | A bookable start, in `timezone` with its offset. Send it as `start` to book. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/appointment-slots?start=2026-11-02T00:00:00-05:00&end=2026-11-03T00:00:00-05:00" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/appointment-slots?start=2026-11-02T00:00:00-05:00&end=2026-11-03T00:00:00-05:00", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "campaign_id": "cccccccc-0000-4000-8000-000000000001", "timezone": "America/New_York", "slot_minutes": 30, "slots": [ { "start": "2026-11-02T09:00:00-05:00" }, { "start": "2026-11-02T09:30:00-05:00" }, { "start": "2026-11-02T11:00:00-05:00" } ] } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:write`. | | 404 | `not_found` | The campaign does not exist or the key cannot reach it. | | 409 | `availability_not_configured` | The campaign's Appointment availability is off or has no hours. Set them in DialEcho; retrying unchanged will not help. | | 422 | `invalid_request` | `campaign_id` is not a UUID, or `start`/`end` is invalid: no UTC offset, `end` not after `start`, or a range longer than 31 days (these carry an empty `error.fields`). | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `PUT /v1/leads/{lead_id}/appointment` · [Book or reschedule](/api/appointments/book) --- # Book or reschedule `PUT /v1/leads/{lead_id}/appointment` Books the lead's appointment at an open time, or moves its existing appointment there. **Scope:** `leads:write` **Access:** The key's member must also be allowed to create appointments (an admin, or a role with the Create appointments action). A lead has one active appointment in its campaign: the first `PUT` books it and later `PUT`s move that same appointment (any other upcoming appointment the lead has there is cancelled). `start` must be a time the slots endpoint would list right now; whether a host has room is decided atomically at booking time. Appointments that have started, completed or been handled are history and are never moved or overwritten: while the lead's current appointment is one of those the request fails with `409 appointment_not_movable` and nothing changes. A new time can be booked once that appointment started more than an hour ago. What happens next is what happens for appointments the AI books: the team member is chosen by the campaign's routing (a moved appointment keeps its member when they have room), the lead's card moves to the appointment stage (a card is created if the lead has none) unless your team placed it in a stage automation may not change, the campaign's connected calendars, team notifications and appointment texts to the lead follow your settings, and the campaign's automated outreach stops while the appointment is active. Retries are safe: sending the `start` the lead already holds answers `200` with `status: "unchanged"` and never books twice, even once that time is no longer listed. The lead must belong to a campaign the key may reach and be one the key's member can see in DialEcho, otherwise `404`, checked before the member's permission to create appointments. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lead_id` | `UUID` | Yes | The lead to book. | ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `start` | `timestamp` | Yes | A `start` from the campaign's open slots, or the same instant in any offset (`2026-11-02T14:30:00Z`). ISO 8601 with a UTC offset; a timestamp without one is rejected. | | `notes` | `string` | No | Saved as the appointment's notes, shown on it in DialEcho's calendar. Omitted or blank keeps the stored notes. Up to 5,000 characters. | ## Response **Status:** `201 Created · 200 OK` `201` when a new appointment was booked; `200` when the lead already had an active appointment (same `appointment_id`). Responses never include the assigned team member or the lead's details. | Name | Type | Description | | --- | --- | --- | | `appointment_id` | `UUID` | The appointment. | | `lead_id` | `UUID` | The lead. | | `start` | `timestamp` | The appointment's start in the campaign's appointment time zone, whatever offset you sent. | | `status` | `string` | `booked`: a new appointment. `rescheduled`: the existing one was moved to `start`. `unchanged`: it was already at `start`. One of `booked`, `rescheduled`, `unchanged`. | ## Example request **curl** ```bash curl -X PUT "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/appointment" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -d '{ "start": "2026-11-02T09:30:00-05:00", "notes": "Prefers the side door." }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/appointment", { method: "PUT", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ "start": "2026-11-02T09:30:00-05:00", "notes": "Prefers the side door." }), }); const data = await response.json(); ``` ## Example response **Status:** `201 Created` ```json { "appointment_id": "77777777-0000-4000-8000-000000000001", "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "start": "2026-11-02T09:30:00-05:00", "status": "booked" } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:write`. | | 403 | `permission_denied` | The key's member may not create appointments. Only for a lead they can see; any other lead is `404`. | | 404 | `not_found` | The lead does not exist or the key cannot reach it. | | 409 | `slot_unavailable` | The time is not open (any more): just taken, too soon, or not one of the campaign's slots. Nothing was booked or changed; re-fetch the slots. | | 409 | `appointment_not_movable` | The lead's current appointment started less than an hour ago, or is completed or handled. Nothing was booked or changed. | | 409 | `availability_not_configured` | The campaign has no appointment hours. | | 409 | `no_hosts_configured` | No team member can take appointments in the campaign (none has an assignment weight above 0%). Fix its routing in DialEcho. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was booked or changed. | | 422 | `invalid_request` | `lead_id` is not a UUID, or the body is invalid: `start` missing or without a UTC offset, `notes` too long, or an unknown field. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/campaigns/{campaign_id}/appointment-slots` · [List open times](/api/appointments/list-slots) - `POST /v1/leads` · [Create or update a lead](/api/leads/create) - `GET /v1/leads/{lead_id}/conversion` · [Retrieve a conversion](/api/reporting/get-conversion) --- # Files Store documents and photos a visitor sends you, for example a utility bill, in a file or image custom field of the lead's pipeline card, exactly as if your team had uploaded them on the board. Files are stored privately; your team opens them from the card's Custom Fields panel and the API never returns a link to them. Both endpoints need a member who may see files: an admin, or a role whose Files contact field is visible (the permission DialEcho's board requires for uploads and deletes). ## Endpoints ### [Upload a file](/api/files/upload) `POST /v1/leads/{lead_id}/files` Adds one file to a file or image custom field of the lead's pipeline card. ### [Delete a file](/api/files/delete) `DELETE /v1/leads/{lead_id}/files/{file_id}` Removes one file from a file or image custom field of the lead's pipeline card. --- # Upload a file `POST /v1/leads/{lead_id}/files` Adds one file to a file or image custom field of the lead's pipeline card. **Scope:** `leads:write` **Access:** The key's member must also be allowed to see files. The body is `multipart/form-data` with exactly one part: the file, in a part named `file`, sent as a file (with a filename). Let your HTTP client set the `Content-Type` and its boundary. The type is recognized from the file's content (its magic bytes): PDF, JPEG, PNG, WebP, HEIC or HEIF. The part's `Content-Type` and the file name's extension are ignored, so a renamed file of another type is rejected with `415`. Image fields take images only, unless your team set the field to also accept files. The stored name is the part's filename without any directory or control characters, shortened to 200 characters (keeping its extension); without a usable name the file is called `upload.`, for example `upload.pdf`. The file goes to the lead's main card in its campaign (never a linked card on another team's pipeline). A follow-through card shares its main card's files, as in DialEcho, so a member who sees the lead only through a follow-through card uploads to those same files. A field can hold several files and an upload never replaces one: to replace a file, upload the new one, then delete the old one. The file records the API key (by its prefix) as its uploader. Retries are safe with the same `Idempotency-Key`: the same file (same lead, field, file name and content) answers `201` with the same `file_id` and stores nothing new. Using the key for anything else is `409 idempotency_conflict`. Create a new key for every new upload. ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | One key per upload, reused on its retries. 16 to 128 printable ASCII characters, no spaces (a UUID works). | ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lead_id` | `UUID` | Yes | The lead. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `field` | `string` | Yes | Slug of a file or image custom field of the lead's campaign. Any other slug (unknown, deleted, another type or another campaign's field) is `422 unknown_field`. | ## Body **Content type:** `multipart/form-data` | Name | Type | Required | Description | | --- | --- | --- | --- | | `file` | `file` | Yes | The document or photo, sent as a file part with a filename. Empty files are rejected. Up to 10 MB (10,485,760 bytes). PDF, JPEG, PNG, WebP, HEIC or HEIF. | ## Response **Status:** `201 Created` | Name | Type | Description | | --- | --- | --- | | `file_id` | `UUID` | The stored file. Keep it to delete the file later. | | `lead_id` | `UUID` | The lead. | | `field` | `string` | The custom field slug. | | `filename` | `string` | The name the file was stored under. | | `content_type` | `string` | The type DialEcho recognized, for example `application/pdf`. | | `size_bytes` | `integer` | Size of the stored file. | ## Example request **curl** ```bash curl -X POST "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/files?field=utility_bill" \ -H "Authorization: Bearer dei_your_key" \ -H "Idempotency-Key: 0f0f0f0f-0000-4000-8000-00000000abcd" \ -F "file=@utility-bill.pdf" ``` **JavaScript** ```js import { openAsBlob } from "node:fs"; const form = new FormData(); form.append("file", await openAsBlob("utility-bill.pdf"), "utility-bill.pdf"); const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/files?field=utility_bill", { method: "POST", headers: { "Authorization": "Bearer dei_your_key", "Idempotency-Key": "0f0f0f0f-0000-4000-8000-00000000abcd", }, body: form, }); const data = await response.json(); ``` ## Example response **Status:** `201 Created` ```json { "file_id": "55555555-0000-4000-8000-000000000001", "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "field": "utility_bill", "filename": "utility-bill.pdf", "content_type": "application/pdf", "size_bytes": 482133 } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:write`. | | 403 | `permission_denied` | The key's member may not see files. Only for a lead they can see; any other lead is `404`. | | 404 | `not_found` | The lead does not exist or the key cannot reach it. | | 409 | `no_pipeline_card` | The lead has no pipeline card. Nothing was stored. | | 409 | `idempotency_conflict` | The `Idempotency-Key` was already used for a different upload. Nothing was stored. | | 413 | `file_too_large` | The file is larger than 10 MB. Nothing was stored. | | 415 | `unsupported_media_type` | The content is not a PDF, JPEG, PNG, WebP, HEIC or HEIF file, or is a PDF sent to an image-only field. Nothing was stored. | | 422 | `unknown_field` | `field` is not a file or image custom field of the lead's campaign. | | 422 | `invalid_request` | `lead_id` is not a UUID, `field` or `Idempotency-Key` is missing or malformed, or the file is empty or the body is not exactly one file part named `file` (these two carry an empty `error.fields`). | | 503 | `dependency_unavailable` | The file could not be stored right now. Retry with the same `Idempotency-Key`. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `DELETE /v1/leads/{lead_id}/files/{file_id}` · [Delete a file](/api/files/delete) - `POST /v1/leads` · [Create or update a lead](/api/leads/create) --- # Delete a file `DELETE /v1/leads/{lead_id}/files/{file_id}` Removes one file from a file or image custom field of the lead's pipeline card. **Scope:** `leads:write` **Access:** The key's member must also be allowed to see files. Works exactly as deleting the file on the board: the stored file is deleted and disappears from the card's Custom Fields panel. Only a file on the lead's main card, in a file or image custom field of its campaign, can be removed; a follow-through card shares those files, so a member who sees the lead only through one removes them the same way. Any other `file_id` (unknown, already removed, or another lead's file) is `404 not_found`, so a retry after a timed-out request that did remove the file also answers `404`: treat it as done. If the stored file cannot be removed the request fails with `502 dependency_unavailable` and the file stays on the card; retry the same request with backoff. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lead_id` | `UUID` | Yes | The lead. | | `file_id` | `UUID` | Yes | The `file_id` the upload returned. | ## Response **Status:** `200 OK` Identifiers only, never the file's name or content. | Name | Type | Description | | --- | --- | --- | | `file_id` | `UUID` | The removed file. | | `lead_id` | `UUID` | The lead. | | `field` | `string` | The custom field slug it was removed from. | | `deleted` | `boolean` | Always `true`. | ## Example request **curl** ```bash curl -X DELETE "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/files/55555555-0000-4000-8000-000000000001" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000001/files/55555555-0000-4000-8000-000000000001", { method: "DELETE", headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "file_id": "55555555-0000-4000-8000-000000000001", "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "field": "utility_bill", "deleted": true } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:write`. | | 403 | `permission_denied` | The key's member may not see files. Only for a lead they can see; any other lead is `404`. | | 404 | `not_found` | The lead or file does not exist, was already removed, belongs to another lead, or the key cannot reach the lead. | | 422 | `invalid_request` | `lead_id` or `file_id` is not a UUID. | | 502 | `dependency_unavailable` | The stored file could not be removed. It stays on the card; retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `POST /v1/leads/{lead_id}/files` · [Upload a file](/api/files/upload) --- # Calls Have one of your campaign's AI voice agents call a lead once, like creating a call in Vapi. You decide when: right after a form, or later from your own scheduled job. DialEcho adds no timers, delays or "did they book" logic of its own and never retries an API call; whether and when to call again is up to you. ## Endpoints ### [Place an AI call](/api/calls/create) `POST /v1/calls` Asks an existing outbound AI agent to call one lead once. --- # Place an AI call `POST /v1/calls` Asks an existing outbound AI agent to call one lead once. **Scope:** `calls:write` **Access:** The key's member needs the Manual call permission in the agent's campaign and must be able to see the lead; creating the lead by `phone` also needs permission to manage that campaign's leads. The caller owns when to call. The call is placed by the agent you name, in its normal outbound mode with its own first message and prompt, exactly as when the campaign calls a lead; it is not treated as a callback. The agent must be an enabled outbound voice agent in a campaign the key may reach (otherwise `404`), and that campaign is the call's campaign. Name the lead with `lead_id` (a lead in the agent's campaign) or with `phone`. With `phone`, the campaign's lead with that number is called as it is; if there is none, it is created exactly as `POST /v1/leads` creates it, before the call is checked, so a call that is then refused still leaves the new lead behind. To send more details, create or update the lead first and call it by `lead_id`. Every call goes through DialEcho's existing automated-call checks, when you send the request and again right before dialing: a number on the do-not-call list or one that cannot be called is refused with `409`, and outside the lead's calling hours the call is accepted as `scheduled` and starts at `scheduled_for` without another request from you. Once a call is requested for a lead, the campaign's own automated dialing leaves that lead alone. `Idempotency-Key` is required so retries never dial a person twice: DialEcho places at most one call per key. The same request with the same key returns the same `call_id` with the call's current `status`, as long as the key's member may still see the lead; otherwise it is `404 not_found`, as a new request for that lead would be. Derive the key from what the call is for, for example `info-form:`, so one event can never cause two calls. Every request, replays included, also draws from the per-key call limit. ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | Identifies this call for your API key. Reusing it with a different body is `409 idempotency_conflict`. 16 to 128 printable ASCII characters, no spaces. | ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `agent_id` | `UUID` | Yes | An enabled outbound voice agent. | | `lead_id` | `UUID` | No | A lead in the agent's campaign, for example the `lead_id` `POST /v1/leads` returned. Send exactly one of `lead_id` or `phone`. | | `phone` | `string` | No | Normalized to E.164 like `POST /v1/leads`. Send exactly one of `lead_id` or `phone`. Up to 40 characters. | | `first_name` | `string` | No | Only with `phone`; used only when the lead is created. Up to 100 characters. | | `last_name` | `string` | No | Only with `phone`; used only when the lead is created. Up to 100 characters. | | `email` | `string` | No | Only with `phone`; used only when the lead is created. A valid email address, up to 254 characters. | ## Response **Status:** `202 Accepted` A new request always answers `queued` or `scheduled`. The other statuses are what a replay of the same request reports once the call has moved on. Responses never contain the lead's personal data. | Name | Type | Description | | --- | --- | --- | | `call_id` | `UUID` | The call; the same value on every replay. | | `lead_id` | `UUID` | The lead being called. | | `status` | `string` | `queued`: starting now (normally within seconds). `scheduled`: outside calling hours, starts at `scheduled_for`. `dialing`: placed. `completed`: answered. `no_answer`: not answered, busy or voicemail; not retried. `failed`: could not be placed (for example the checks before dialing refused it), or DialEcho restarted while starting it and it is never dialed again. `canceled`: canceled in DialEcho before it was placed. One of `queued`, `scheduled`, `dialing`, `completed`, `no_answer`, `failed`, `canceled`. | | `scheduled_for` | `timestamp` | Only present when `status` is `scheduled`: when the call starts (RFC 3339, UTC). | ## Example request **curl** ```bash curl -X POST "https://development.agencydevworks.ai/v1/calls" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: info-form:aaaaaaaa-0000-4000-8000-000000000001" \ -d '{ "agent_id": "99999999-0000-4000-8000-000000000001", "lead_id": "aaaaaaaa-0000-4000-8000-000000000001" }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/calls", { method: "POST", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", "Idempotency-Key": "info-form:aaaaaaaa-0000-4000-8000-000000000001", }, body: JSON.stringify({ "agent_id": "99999999-0000-4000-8000-000000000001", "lead_id": "aaaaaaaa-0000-4000-8000-000000000001" }), }); const data = await response.json(); ``` ## Example response **Status:** `202 Accepted` ```json { "call_id": "33333333-0000-4000-8000-000000000001", "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "status": "scheduled", "scheduled_for": "2026-11-02T14:00:00Z" } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `calls:write`. | | 403 | `permission_denied` | The key's member may not place AI calls in the agent's campaign, or (with `phone`) may not manage its leads. | | 404 | `not_found` | The agent does not exist, is not an enabled outbound agent or is outside the key's campaigns, or the lead is not in the agent's campaign or the member cannot see it (a replay of an earlier request included). | | 409 | `do_not_call` | The number is on the do-not-call list. Final; do not retry. | | 409 | `invalid_phone` | The lead's number cannot be called. Final; do not retry. | | 409 | `not_callable` | The lead is outside calling hours and no later calling window can be found. | | 409 | `idempotency_conflict` | The `Idempotency-Key` was already used for a different request. Nothing was dialed. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was dialed. | | 422 | `invalid_request` | The body or header is invalid: a missing or malformed `Idempotency-Key`, unknown fields, an invalid phone or email, or not exactly one of `lead_id` and `phone` (or names sent with `lead_id`), which is reported as field `body`. | | 503 | `dependency_unavailable` | The do-not-call list or the campaign's calling-hours settings cannot be read right now. Nothing was placed; retry with the same `Idempotency-Key`. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `POST /v1/leads` · [Create or update a lead](/api/leads/create) - `GET /v1/leads/{lead_id}/conversion` · [Retrieve a conversion](/api/reporting/get-conversion) --- # Reporting What happened to the leads you sent and where they are now: the conversion report (first AI call, appointment booked and held, deal won or lost, with its value) and the campaign's pipeline board. Both are read live from DialEcho on every request and need `leads:read`. Only leads the key's member can see in DialEcho are returned; the others are skipped, so a page can hold fewer rows than `limit`, or none, while `has_more` is still `true`. Keep following `next_cursor`. ## Endpoints ### [List conversions](/api/reporting/list-conversions) `GET /v1/campaigns/{campaign_id}/conversions` Returns one conversion row per lead of a campaign, oldest first. ### [Retrieve a conversion](/api/reporting/get-conversion) `GET /v1/leads/{lead_id}/conversion` Returns the conversion row of one lead, exactly as the list returns it. ### [Retrieve the pipeline](/api/reporting/pipeline-board) `GET /v1/campaigns/{campaign_id}/pipeline` Returns the campaign's pipelines with every stage in board order and how many of the campaign's leads are in each. ### [List a stage's leads](/api/reporting/stage-leads) `GET /v1/campaigns/{campaign_id}/pipeline/stages/{stage}/leads` Returns the campaign's leads in one stage, the most recent arrival first. --- # List conversions `GET /v1/campaigns/{campaign_id}/conversions` Returns one conversion row per lead of a campaign, oldest first. **Scope:** `leads:read` Rows are ordered by `created_at`, oldest first, so a lead created while you page shows up on a later page and rows already returned are never repeated. Use it to see which marketing leads became customers and to send those outcomes back to an ad platform. A deal is closed when its card is in a stage whose outcome is set to Won in the pipeline's stage settings, and lost in a stage whose outcome is Lost; the stage's name does not matter. Set the outcome of every winning and losing stage in DialEcho; leads already in those stages then report the close from when they entered the stage. The campaign must be one the key may reach, otherwise `404`. Reading does not need the member's permission to manage leads. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | Yes | A campaign the key may reach. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `created_after` | `timestamp` | No | Only leads created at or after this time. ISO 8601 with a UTC offset, for example `2026-11-01T00:00:00Z` (URL-encode `+` as `%2B`). | | `created_before` | `timestamp` | No | Only leads created before this time. After `created_after`. | | `milestone` | `string` | No | Only leads that reached it: `called` (`first_call_at` set), `appointment_booked`, `appointment_held` (`appointment_status` is `held`), `closed` (`closed_at` set) or `lost` (`lost_at` set). One of `called`, `appointment_booked`, `appointment_held`, `closed`, `lost`. | | `limit` | `integer` | No | Rows per page. 1 to 100. Default: `50`. | | `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. Opaque: never build or edit one. Up to 4,096 characters. | ## Response **Status:** `200 OK` A lead that has not got anywhere yet has every milestone `null` and, if it is not on the board, `"card": null`. Every key is always present. | Name | Type | Description | | --- | --- | --- | | `data` | `array of objects` | Conversion rows, oldest first. | | `data.lead_id` | `UUID` | The lead. | | `data.campaign_id` | `UUID` | The lead's campaign. | | `data.created_at` | `timestamp` | When the lead was created in DialEcho (RFC 3339, UTC). | | `data.contact` | `object` | The lead's details as `GET /v1/leads/{lead_id}` returns them, with the same field visibility: a field the member's role hides is `null`, one it masks comes back masked. | | `data.contact.first_name` | `string \| null` | First name. | | `data.contact.last_name` | `string \| null` | Last name. | | `data.contact.email` | `string \| null` | Email address. | | `data.contact.phone` | `string \| null` | Phone number in E.164 (`+14155550132`); a stored number that is not a valid phone number is returned as stored. | | `data.contact.city` | `string \| null` | City. | | `data.contact.state` | `string \| null` | State. | | `data.contact.postal_code` | `string \| null` | ZIP or postal code. | | `data.contact.email_sha256` | `string \| null` | SHA-256 (lowercase hex) of the email trimmed and lowercased: Meta's hashed `em`. `null` without an email, or unless the member sees the email in full. | | `data.contact.phone_sha256` | `string \| null` | SHA-256 (lowercase hex) of the number's digits with country code and no `+` (`14155550132`): Meta's hashed `ph`. `null` without a valid number, or unless the member sees the phone in full. | | `data.attribution` | `object \| null` | The attribution stored on the lead under the 16 `attribution` keys `POST /v1/leads` accepts (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `utm_id`, `adset_id`, `ad_id`, `fbclid`, `fbc`, `fbp`, `gclid`, `meta_event_id`, `page_url`, `form_name`, `referrer`), whether sent through the API or set another way such as an import. Every key is present, `null` when never set. The whole object is `null` when the member's role hides custom fields; when it masks them, set values read `Restricted`. | | `data.milestones` | `object` | How far the lead got. Timestamps are RFC 3339 in UTC; `null` until the lead reaches the milestone. | | `data.milestones.first_call_at` | `timestamp \| null` | When the first outbound call to the lead by one of the campaign's AI agents started: a `POST /v1/calls` call, a campaign or a pipeline agent's call. Calls by your team and calls the lead made are not counted. | | `data.milestones.first_call_outcome` | `string \| null` | How that call went: `reached` (a real conversation), `answered` (picked up but no conversation), `voicemail`, `no_answer` (not answered, busy or not connected) or `in_progress`. One of `in_progress`, `reached`, `answered`, `voicemail`, `no_answer`. | | `data.milestones.appointment_booked_at` | `timestamp \| null` | When the lead's first appointment in the campaign was booked, however it was booked, even if it was later moved or cancelled. | | `data.milestones.appointment_at` | `timestamp \| null` | When the appointment is or was scheduled: the first one that was held; if none was held, the latest one not cancelled; if all were cancelled, the latest one. | | `data.milestones.appointment_status` | `string \| null` | That appointment's status. Attendance your team records decides (shown up is `held`, no-show is `no_show`); otherwise `held` when your team's appointment call reached the lead and `no_show` when your team recorded nobody answered. An unanswered call with nothing recorded leaves it `scheduled`. One of `scheduled`, `held`, `no_show`, `cancelled`. | | `data.milestones.appointment_held_at` | `timestamp \| null` | For a `held` appointment, when the call that reached the lead started; for one only marked as shown up, its scheduled start. | | `data.milestones.closed_at` | `timestamp \| null` | When the card entered the Won stage it is in now (a deal reopened and won again reports the latest win). `null` unless `card.deal_status` is `won`. | | `data.milestones.lost_at` | `timestamp \| null` | The same for a Lost stage. `null` unless `card.deal_status` is `lost`. | | `data.card` | `object \| null` | The card `GET /v1/leads/{lead_id}` returns as `card`, or `null` if the lead has none the member can open: its main card (the deal), or, for a member who may open only a follow-through card of the lead, that card with its own pipeline, stage, `deal_status`, `closed_at` and `lost_at` and the deal value it shares with the main card. Nothing of a card the member may not open is shown. Always shown, whatever the member's field visibility. | | `data.card.id` | `UUID` | The card. | | `data.card.pipeline_id` | `string \| null` | The card's pipeline. | | `data.card.pipeline_name` | `string \| null` | The pipeline's name. | | `data.card.stage` | `string \| null` | Slug of the current stage. | | `data.card.stage_name` | `string \| null` | Name of the current stage. | | `data.card.deal_status` | `string` | `abandoned` when your team marked the deal abandoned, otherwise the outcome set on the card's stage in DialEcho (Won, Lost or Open), whatever the stage is called. A follow-through card the deal moves into after it is won is reported only to a member who may open that card and not the main card. One of `open`, `won`, `lost`, `abandoned`. | | `data.card.deal_value` | `number \| null` | The deal value, shared by the main card and its follow-through cards, or `null`. | | `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when there is none. | | `has_more` | `boolean` | `true` while more pages may follow. Keep paging until it is `false`. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/conversions?created_after=2026-11-01T00:00:00Z&milestone=closed&limit=50" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/conversions?created_after=2026-11-01T00:00:00Z&milestone=closed&limit=50", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "data": [ { "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "created_at": "2026-11-02T15:55:55Z", "contact": { "first_name": "Pat", "last_name": "Lee", "email": "pat@example.com", "phone": "+14155550132", "city": "San Francisco", "state": "CA", "postal_code": "94105", "email_sha256": "fe9733fcc96501276776fc927df08a395835461a2c5b12c65ebab3454877e227", "phone_sha256": "11072303eb5c8cb5c5a1ebb762f11b37b07131265b46fdfe1276196bfbd3ca51" }, "attribution": { "form_name": "quote-step-1", "page_url": "https://www.example.com/get-a-quote", "referrer": null, "utm_source": "facebook", "utm_medium": "paid_social", "utm_campaign": "fall-promo", "utm_term": null, "utm_content": null, "utm_id": "120200000000000001", "adset_id": "120200000000000002", "ad_id": "120200000000000003", "fbclid": "IwAR0exampleClickId", "fbc": null, "fbp": "fb.1.1791900000000.1234567890", "gclid": null, "meta_event_id": null }, "milestones": { "first_call_at": "2026-11-02T15:56:04Z", "first_call_outcome": "reached", "appointment_booked_at": "2026-11-02T15:59:41Z", "appointment_at": "2026-11-04T18:00:00Z", "appointment_status": "held", "appointment_held_at": "2026-11-04T18:02:13Z", "closed_at": "2026-11-09T16:30:00Z", "lost_at": null }, "card": { "id": "dddddddd-0000-4000-8000-000000000001", "pipeline_id": "eeeeeeee-0000-4000-8000-000000000001", "pipeline_name": "Sales", "stage": "won", "stage_name": "Won", "deal_status": "won", "deal_value": 18500.0 } } ], "next_cursor": null, "has_more": false } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:read`. Checked before the query is validated. | | 404 | `not_found` | The campaign does not exist or the key cannot reach it. | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again from the first page. | | 422 | `invalid_cursor` | The cursor was not issued to this key for this request, was changed, or was sent with different parameters than the page it came from. | | 422 | `invalid_request` | A parameter is invalid: `campaign_id` not a UUID, a timestamp without a UTC offset, `created_before` not after `created_after`, an unknown `milestone`, a `limit` outside 1 to 100 or a `cursor` over 4,096 characters. `error.fields` names it (`query.milestone`, `campaign_id`, ...). | | 503 | `dependency_unavailable` | The report or the access check is temporarily unavailable. Retry the same request with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Sending conversions to Meta DialEcho does not send anything to Meta. Your server, or your marketing team's tool, reads this report and sends the events to Meta's Conversions API (`POST https://graph.facebook.com/{api-version}/{pixel-id}/events`), so Meta's optimization learns from leads that became appointments and sales. Keep the `event_id`s you have sent and send each event once. Pull at least daily: Meta only accepts recent `event_time`s (7 days for most events) and deals often close weeks after the lead. A daily job requesting `?created_after=&milestone=closed` (and `milestone=appointment_booked`) catches every close while it is still recent. `action_source` says where the conversion happened: `phone_call` for an appointment or deal made on a call (the usual case here), `website` for a form or a booking your site made (Meta then requires the visitor's user agent and page, which the report does not have, so send those from your website's server when they happen), `other` for in person. | Meta `event_name` | Send it for rows where | `event_time` (Unix seconds) | `event_id` | `custom_data` | | --- | --- | --- | --- | --- | | `Lead` | Only leads your website's server did not already send, for example a lead that phoned you. | `created_at` | `attribution.meta_event_id` when set (deduplicates against the Pixel), otherwise `dialecho-lead-{lead_id}` | none | | `Schedule` | `milestones.appointment_booked_at` is set | `appointment_booked_at` | `dialecho-schedule-{lead_id}` | none | | `Purchase` | `milestones.closed_at` is set | `closed_at` | `dialecho-purchase-{lead_id}` | `{"value": card.deal_value, "currency": "USD"}`; DialEcho stores no currency and shows deal values in US dollars | ## Meta user_data from a row Send these `user_data` keys with every event. Omit a key whose source is `null`. | Meta key | From the row | | --- | --- | | `em` | `[contact.email_sha256]` (already hashed) | | `ph` | `[contact.phone_sha256]` (already hashed) | | `fn`, `ln` | SHA-256 of `contact.first_name` / `last_name`, trimmed and lowercased | | `ct` | SHA-256 of `contact.city`, lowercased with spaces and punctuation removed | | `st` | SHA-256 of `contact.state` as a lowercase 2-letter code (`ca`) | | `zp` | SHA-256 of the first 5 digits of `contact.postal_code` (US) | | `country` | SHA-256 of `us` for US leads | | `external_id` | SHA-256 of `lead_id`, the same on every event of a lead | | `fbc` | `attribution.fbc` when set; otherwise, when `attribution.fbclid` is set, `fb.1.{created_at in Unix milliseconds}.{fbclid}`. Not hashed. | | `fbp` | `attribution.fbp` when set. Not hashed. | ## Related endpoints - `GET /v1/leads/{lead_id}/conversion` · [Retrieve a conversion](/api/reporting/get-conversion) - `POST /v1/leads` · [Create or update a lead](/api/leads/create) - `GET /v1/campaigns/{campaign_id}/pipeline` · [Retrieve the pipeline](/api/reporting/pipeline-board) --- # Retrieve a conversion `GET /v1/leads/{lead_id}/conversion` Returns the conversion row of one lead, exactly as the list returns it. **Scope:** `leads:read` The same row, field visibility included, as `GET /v1/campaigns/{campaign_id}/conversions`. The lead must belong to a campaign the key may reach and be one the key's member can see in DialEcho, otherwise `404`. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `lead_id` | `UUID` | Yes | The lead. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `lead_id` | `UUID` | The lead. | | `campaign_id` | `UUID` | The lead's campaign. | | `created_at` | `timestamp` | When the lead was created in DialEcho (RFC 3339, UTC). | | `contact` | `object` | The lead's details as `GET /v1/leads/{lead_id}` returns them, with the same field visibility: a field the member's role hides is `null`, one it masks comes back masked. | | `contact.first_name` | `string \| null` | First name. | | `contact.last_name` | `string \| null` | Last name. | | `contact.email` | `string \| null` | Email address. | | `contact.phone` | `string \| null` | Phone number in E.164 (`+14155550132`); a stored number that is not a valid phone number is returned as stored. | | `contact.city` | `string \| null` | City. | | `contact.state` | `string \| null` | State. | | `contact.postal_code` | `string \| null` | ZIP or postal code. | | `contact.email_sha256` | `string \| null` | SHA-256 (lowercase hex) of the email trimmed and lowercased: Meta's hashed `em`. `null` without an email, or unless the member sees the email in full. | | `contact.phone_sha256` | `string \| null` | SHA-256 (lowercase hex) of the number's digits with country code and no `+` (`14155550132`): Meta's hashed `ph`. `null` without a valid number, or unless the member sees the phone in full. | | `attribution` | `object \| null` | The attribution stored on the lead under the 16 `attribution` keys `POST /v1/leads` accepts (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `utm_id`, `adset_id`, `ad_id`, `fbclid`, `fbc`, `fbp`, `gclid`, `meta_event_id`, `page_url`, `form_name`, `referrer`), whether sent through the API or set another way such as an import. Every key is present, `null` when never set. The whole object is `null` when the member's role hides custom fields; when it masks them, set values read `Restricted`. | | `milestones` | `object` | How far the lead got. Timestamps are RFC 3339 in UTC; `null` until the lead reaches the milestone. | | `milestones.first_call_at` | `timestamp \| null` | When the first outbound call to the lead by one of the campaign's AI agents started: a `POST /v1/calls` call, a campaign or a pipeline agent's call. Calls by your team and calls the lead made are not counted. | | `milestones.first_call_outcome` | `string \| null` | How that call went: `reached` (a real conversation), `answered` (picked up but no conversation), `voicemail`, `no_answer` (not answered, busy or not connected) or `in_progress`. One of `in_progress`, `reached`, `answered`, `voicemail`, `no_answer`. | | `milestones.appointment_booked_at` | `timestamp \| null` | When the lead's first appointment in the campaign was booked, however it was booked, even if it was later moved or cancelled. | | `milestones.appointment_at` | `timestamp \| null` | When the appointment is or was scheduled: the first one that was held; if none was held, the latest one not cancelled; if all were cancelled, the latest one. | | `milestones.appointment_status` | `string \| null` | That appointment's status. Attendance your team records decides (shown up is `held`, no-show is `no_show`); otherwise `held` when your team's appointment call reached the lead and `no_show` when your team recorded nobody answered. An unanswered call with nothing recorded leaves it `scheduled`. One of `scheduled`, `held`, `no_show`, `cancelled`. | | `milestones.appointment_held_at` | `timestamp \| null` | For a `held` appointment, when the call that reached the lead started; for one only marked as shown up, its scheduled start. | | `milestones.closed_at` | `timestamp \| null` | When the card entered the Won stage it is in now (a deal reopened and won again reports the latest win). `null` unless `card.deal_status` is `won`. | | `milestones.lost_at` | `timestamp \| null` | The same for a Lost stage. `null` unless `card.deal_status` is `lost`. | | `card` | `object \| null` | The card `GET /v1/leads/{lead_id}` returns as `card`, or `null` if the lead has none the member can open: its main card (the deal), or, for a member who may open only a follow-through card of the lead, that card with its own pipeline, stage, `deal_status`, `closed_at` and `lost_at` and the deal value it shares with the main card. Nothing of a card the member may not open is shown. Always shown, whatever the member's field visibility. | | `card.id` | `UUID` | The card. | | `card.pipeline_id` | `string \| null` | The card's pipeline. | | `card.pipeline_name` | `string \| null` | The pipeline's name. | | `card.stage` | `string \| null` | Slug of the current stage. | | `card.stage_name` | `string \| null` | Name of the current stage. | | `card.deal_status` | `string` | `abandoned` when your team marked the deal abandoned, otherwise the outcome set on the card's stage in DialEcho (Won, Lost or Open), whatever the stage is called. A follow-through card the deal moves into after it is won is reported only to a member who may open that card and not the main card. One of `open`, `won`, `lost`, `abandoned`. | | `card.deal_value` | `number \| null` | The deal value, shared by the main card and its follow-through cards, or `null`. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000002/conversion" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/leads/aaaaaaaa-0000-4000-8000-000000000002/conversion", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "lead_id": "aaaaaaaa-0000-4000-8000-000000000002", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "created_at": "2026-11-03T10:21:07Z", "contact": { "first_name": "Sam", "last_name": null, "email": "sam@example.com", "phone": "+18035550142", "city": null, "state": "SC", "postal_code": null, "email_sha256": "cd25a6171969f2a3c6e35c7667e3908ef1bd2424241db04411a0eec454ca6c16", "phone_sha256": "70d177f4d52fc4982e5a28ed456060c15b9e4654a415c25be1eb9ade17a0a2ea" }, "attribution": { "form_name": "quote-step-1", "page_url": "https://www.example.com/get-a-quote", "referrer": null, "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "fall-promo", "utm_term": null, "utm_content": null, "utm_id": null, "adset_id": null, "ad_id": null, "fbclid": null, "fbc": null, "fbp": null, "gclid": "Cj0KCQexampleGclid", "meta_event_id": null }, "milestones": { "first_call_at": "2026-11-03T10:21:30Z", "first_call_outcome": "voicemail", "appointment_booked_at": null, "appointment_at": null, "appointment_status": null, "appointment_held_at": null, "closed_at": null, "lost_at": null }, "card": { "id": "dddddddd-0000-4000-8000-000000000001", "pipeline_id": "eeeeeeee-0000-4000-8000-000000000001", "pipeline_name": "Sales", "stage": "contacted", "stage_name": "Contacted", "deal_status": "open", "deal_value": null } } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:read`. | | 404 | `not_found` | The lead does not exist or the key cannot reach it. | | 422 | `invalid_request` | `lead_id` is not a UUID. | | 503 | `dependency_unavailable` | The report or the access check is temporarily unavailable. Retry the same request with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/campaigns/{campaign_id}/conversions` · [List conversions](/api/reporting/list-conversions) - `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get) --- # Retrieve the pipeline `GET /v1/campaigns/{campaign_id}/pipeline` Returns the campaign's pipelines with every stage in board order and how many of the campaign's leads are in each. **Scope:** `leads:read` The board is always the campaign's own. A pipeline can be shared by several campaigns: the board lists every stage of the pipeline but counts only this campaign's cards, never another campaign's leads on the same pipeline. Archived cards are not counted, as in DialEcho. The default pipeline (the one `POST /v1/leads` adds new leads to) comes first, then the campaign's other pipelines in the order they were added to it, for example a follow-through pipeline won deals move into. If the member's role only lets them open some pipelines, only those are listed. A lead whose deal moved into a follow-through pipeline has a card on both boards and is counted on both. `lead_count` is one database count per stage, so the board costs the same for 20 leads or 20,000. It counts every card of the campaign in the stage, including leads the member cannot see, cards they may not open and cards whose lead was deleted, so the stage's lead list can be shorter. The board never contains names, contact details or deal values. The campaign must be one the key may reach, otherwise `404`. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | Yes | A campaign the key may reach. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `campaign_id` | `UUID` | The campaign. | | `pipelines` | `array of objects` | The campaign's pipelines, default first. Empty when it has none yet or the member may open none. | | `pipelines.id` | `UUID` | The pipeline. Pass it as `pipeline_id` to list a stage of a pipeline that is not the default. | | `pipelines.name` | `string \| null` | The pipeline's name. | | `pipelines.is_default` | `boolean` | `true` for the campaign's default pipeline, where new leads are added. | | `pipelines.stages` | `array of objects` | Every stage in board order (left to right), including empty ones. | | `pipelines.stages.slug` | `string` | The stage slug, as `card.stage` and stage moves use it. Unique within its pipeline only: two pipelines can both have a `new` stage. | | `pipelines.stages.name` | `string \| null` | The stage name on the board. | | `pipelines.stages.outcome` | `string` | The outcome set for the stage in DialEcho, whatever it is called. One of `open`, `won`, `lost`. | | `pipelines.stages.position` | `integer` | Position on the board as stored; positions can skip numbers. | | `pipelines.stages.lead_count` | `integer` | How many of the campaign's cards are in the stage now. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/pipeline" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/pipeline", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "campaign_id": "cccccccc-0000-4000-8000-000000000001", "pipelines": [ { "id": "eeeeeeee-0000-4000-8000-000000000001", "name": "Sales", "is_default": true, "stages": [ { "slug": "new", "name": "New", "outcome": "open", "position": 0, "lead_count": 42 }, { "slug": "contacted", "name": "Contacted", "outcome": "open", "position": 1, "lead_count": 23 }, { "slug": "appointment", "name": "Appointment Set", "outcome": "open", "position": 2, "lead_count": 17 }, { "slug": "won", "name": "Won", "outcome": "won", "position": 3, "lead_count": 5 }, { "slug": "lost", "name": "Lost", "outcome": "lost", "position": 4, "lead_count": 9 } ] }, { "id": "eeeeeeee-0000-4000-8000-000000000002", "name": "Fulfillment", "is_default": false, "stages": [ { "slug": "new", "name": "Kickoff", "outcome": "open", "position": 0, "lead_count": 3 }, { "slug": "done", "name": "Delivered", "outcome": "open", "position": 1, "lead_count": 2 } ] } ] } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:read`. | | 404 | `not_found` | The campaign does not exist or the key cannot reach it. | | 422 | `invalid_request` | `campaign_id` is not a UUID. | | 503 | `dependency_unavailable` | The report or the access check is temporarily unavailable. Retry the same request with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/campaigns/{campaign_id}/pipeline/stages/{stage}/leads` · [List a stage's leads](/api/reporting/stage-leads) - `PUT /v1/leads/{lead_id}/stage` · [Move a lead's card](/api/leads/move-stage) - `GET /v1/campaigns/{campaign_id}/conversions` · [List conversions](/api/reporting/list-conversions) --- # List a stage's leads `GET /v1/campaigns/{campaign_id}/pipeline/stages/{stage}/leads` Returns the campaign's leads in one stage, the most recent arrival first. **Scope:** `leads:read` `{stage}` is a stage `slug` from the board. Without `pipeline_id` it is a stage of the campaign's default pipeline. When the campaign has more than one pipeline, send `pipeline_id` for a stage of any pipeline but the default: slugs repeat across pipelines, so `new` without `pipeline_id` is always the default pipeline's `new`. Rows are ordered by `stage_entered_at`, newest first, and rows already returned are never repeated. A lead that enters the stage while you page is at the top of the list, not on a later page: read the first page again for new arrivals. The campaign must be one the key may reach, otherwise `404`. Only leads the key's member can see are listed, and only on cards they may open: a card outside the assigned records their role limits them to is left out even when they see its lead through another card. Contact fields follow the member's field visibility as in the conversion report; `form_name` is attribution, so it is `null` when the role hides custom fields and `Restricted` when it masks them. `assigned_member`, `stage_entered_at` and `deal_value` are always shown. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | Yes | A campaign the key may reach. | | `stage` | `string` | Yes | The stage slug, as the board lists it. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `pipeline_id` | `UUID` | No | The stage's pipeline, a `pipelines[].id` from the board. It must be one of the campaign's pipelines the member may open. Default: the campaign's default pipeline. | | `limit` | `integer` | No | Rows per page. 1 to 100. Default: `50`. | | `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. Opaque: never build or edit one. Up to 4,096 characters. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `data` | `array of objects` | Leads in the stage, most recent arrival first. | | `data.lead_id` | `UUID` | The lead. Read it with `GET /v1/leads/{lead_id}` and its outcome with `GET /v1/leads/{lead_id}/conversion`. | | `data.first_name` | `string \| null` | First name. | | `data.last_name` | `string \| null` | Last name. | | `data.phone` | `string \| null` | Phone number in E.164; a stored number that is not a valid phone number is returned as stored. | | `data.email` | `string \| null` | Email address. | | `data.assigned_member` | `object \| null` | The team member the lead's card on this pipeline is assigned to, or `null`. A follow-through card has its own assignee. | | `data.assigned_member.id` | `UUID` | The member. | | `data.assigned_member.name` | `string \| null` | Their DialEcho profile name. | | `data.stage_entered_at` | `timestamp \| null` | When the card entered its current stage. | | `data.deal_value` | `number \| null` | The deal value. A follow-through card shows the value of the deal it came from, as in DialEcho. | | `data.created_at` | `timestamp \| null` | When the lead was created in DialEcho. | | `data.form_name` | `string \| null` | The `attribution.form_name` the lead was sent with. | | `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when there is none. | | `has_more` | `boolean` | `true` while more pages may follow. Keep paging until it is `false`. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/pipeline/stages/appointment/leads?limit=2" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/campaigns/cccccccc-0000-4000-8000-000000000001/pipeline/stages/appointment/leads?limit=2", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "data": [ { "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "first_name": "Pat", "last_name": "Lee", "phone": "+14155550132", "email": "pat@example.com", "assigned_member": { "id": "ffffffff-0000-4000-8000-000000000001", "name": "Jordan Rivera" }, "stage_entered_at": "2026-11-02T16:04:12Z", "deal_value": null, "created_at": "2026-11-02T15:55:55Z", "form_name": "quote-step-1" }, { "lead_id": "aaaaaaaa-0000-4000-8000-000000000002", "first_name": "Sam", "last_name": null, "phone": "+18035550142", "email": null, "assigned_member": null, "stage_entered_at": "2026-11-01T15:30:00Z", "deal_value": null, "created_at": "2026-11-01T15:21:07Z", "form_name": null } ], "next_cursor": "v2.exampleOpaqueCursor", "has_more": true } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `leads:read`. Checked before the query is validated. | | 404 | `not_found` | The campaign does not exist or the key cannot reach it; or `pipeline_id` is not one of the campaign's pipelines the member may open (without `pipeline_id`: the campaign has no such default pipeline). | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again from the first page. | | 422 | `invalid_cursor` | The cursor was not issued to this key for this request, was changed, or was sent with different parameters than the page it came from. | | 422 | `unknown_stage` | `{stage}` is not a stage of that pipeline. `error.valid_stages` lists its slugs in board order. | | 422 | `invalid_request` | `campaign_id` or `pipeline_id` is not a UUID, `limit` is outside 1 to 100, or `cursor` is over 4,096 characters. `error.fields` names it (`campaign_id`, `query.pipeline_id`, `query.limit`). | | 503 | `dependency_unavailable` | The report or the access check is temporarily unavailable. Retry the same request with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/campaigns/{campaign_id}/pipeline` · [Retrieve the pipeline](/api/reporting/pipeline-board) - `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get) - `GET /v1/leads/{lead_id}/conversion` · [Retrieve a conversion](/api/reporting/get-conversion) --- # Lead actions A lead action is a button on a lead in DialEcho that opens your own web app for that lead, for example a quoting or scheduling tool your team works in. It is set up on the API key your app uses: a button label and the URL of your page. When a team member clicks it, DialEcho opens your URL with a `dialecho_token` query parameter: a signed launch token saying which member opened which lead, valid for 10 minutes. Treat it as opaque. Your server sends it to `POST /v1/lead-actions/verify` with that API key and trusts only the response, then starts its own session and redirects to the same page without the token, so it does not stay in the browser history or leak in a `Referer` header. ## Endpoints ### [Verify a launch token](/api/lead-actions/verify) `POST /v1/lead-actions/verify` Checks a lead-action token and returns the team member and lead it was issued for. --- # Verify a launch token `POST /v1/lead-actions/verify` Checks a lead-action token and returns the team member and lead it was issued for. **Scope:** none, any valid key **Access:** Any valid key may call it, but only the key the lead action belongs to can verify that action's tokens. Verify from your server only: the key must never reach a browser. A token can be verified more than once until it expires, so anyone who obtains the full URL in those 10 minutes can use it; keep it out of logs and analytics. With a `leads:read` key you can then read the lead with `GET /v1/leads/{lead_id}`, and with `pipeline:write` move its card for the member by sending `member.id` as `acting_member_id`. Every failed check gets the same `404 invalid_token`: a malformed, altered or expired token, one issued for another key, a key that no longer has a lead action, a key or member (the key's own or the one who clicked) that can no longer reach the lead's campaign, a member who clicked but can no longer open the card or is no longer active, or a lead or card that no longer exists. Ask the team member to click the button again; retrying the same token will not help. ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `token` | `string` | Yes | The `dialecho_token` query parameter, exactly as received. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `member` | `object` | The team member who clicked the button. | | `member.id` | `UUID` | Their DialEcho member id. | | `member.name` | `string \| null` | Their profile name, or `null`. | | `member.email` | `string \| null` | Their email, or `null`. | | `lead_id` | `UUID` | The lead the button was clicked on. | | `pipeline_lead_id` | `UUID` | The pipeline card the button was clicked on; it belongs to that lead. | | `campaign_id` | `UUID` | The card's campaign. | | `expires_at` | `timestamp` | When the token expires (ISO 8601, UTC). | ## Example request **curl** ```bash curl -X POST "https://development.agencydevworks.ai/v1/lead-actions/verify" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -d '{ "token": "eyJ2IjoxLCJleGFtcGxlIjp0cnVlfQ.exampleSignature" }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/lead-actions/verify", { method: "POST", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ "token": "eyJ2IjoxLCJleGFtcGxlIjp0cnVlfQ.exampleSignature" }), }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "member": { "id": "ffffffff-0000-4000-8000-000000000001", "name": "Jordan Rivera", "email": "jordan@example.com" }, "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "pipeline_lead_id": "dddddddd-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "expires_at": "2026-11-02T15:10:00Z" } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 404 | `invalid_token` | The token is not valid for this key, for any reason. Ask the member to click the button again. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read. | | 422 | `invalid_request` | The body is not `{"token": ""}`. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/leads/{lead_id}` · [Retrieve a lead](/api/leads/get) - `PUT /v1/leads/{lead_id}/stage` · [Move a lead's card](/api/leads/move-stage) --- # Inbox Your campaigns' email conversations, as your team sees them in DialEcho's inbox: read threads and their messages, poll for new inbound replies, answer them from the campaign mailbox or the reply address, and mark threads read or archived. Contact campaign inboxes (`contact`) and real-estate offer campaign inboxes (`realestate`) are both served; every conversation and message names its `domain`. Reads need `inbox:read`; replies and read or archive changes need `inbox:reply`, which does not include reading. The key's member must be able to use the campaign's inbox in DialEcho (its message and email permissions) and see the contact: other conversations are left out of lists and reported as `404`. Addresses follow the member's email visibility (hidden ones are `null` or left out, masked ones look like `p***@example.com`), and when the role masks messages, `text` is `Restricted`, `html` is `null` and `attachments` is empty. To act on replies as they arrive, poll the inbound message feed and answer with a reply. The feed delivers each message at least once, so deduplicate by message `id`. ## Endpoints ### [List conversations](/api/inbox/list-conversations) `GET /v1/inbox/conversations` Returns the conversations that changed in a time window, oldest change first. ### [Retrieve a conversation](/api/inbox/get-conversation) `GET /v1/inbox/conversations/{conversation_id}` Returns a conversation with its messages, oldest first. ### [List inbound messages](/api/inbox/list-inbound) `GET /v1/inbox/inbound-messages` A polling feed of new inbound emails, each with its conversation's state. ### [Reply to a conversation](/api/inbox/reply) `POST /v1/inbox/conversations/{conversation_id}/replies` Emails a reply to the conversation's latest inbound message, from the campaign mailbox or the reply address. ### [Update a conversation](/api/inbox/update-conversation) `PATCH /v1/inbox/conversations/{conversation_id}` Marks a conversation read or unread, or archives or restores it. --- # List conversations `GET /v1/inbox/conversations` Returns the conversations that changed in a time window, oldest change first. **Scope:** `inbox:read` **Access:** The key's member must be able to use the campaign's email inbox in DialEcho (its message and email permissions) and see the contact; other conversations are left out. A change scan, not the inbox's newest-first view: conversations are ordered by `updated_at` (then `id`), oldest first, and `updated_at` moves on every new message and every read or archive change. The first page fixes the end of the window, returned as `scan_through`; a conversation that changes after it waits for the next scan. To keep a copy in sync, scan with `updated_since` and follow `next_cursor` until it is `null`. Start the next scan with `updated_since` set to the last `scan_through` minus 5 minutes, and keep the newest copy of each conversation by `id`. Deleted conversations are never listed: if one you have stored answers `404` later, treat it as deleted. Only conversations the key's member can see are returned. Each request looks at up to 1,000 conversations, so a page can hold fewer than `limit`, or none, while `next_cursor` is still set. Lists carry no totals. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | No | Only this campaign. It must be a campaign whose inbox the key can read, otherwise `404`. | | `domain` | `string` | No | Only this inbox. Leave it out for both. One of `contact`, `realestate`. | | `status` | `string` | No | Only conversations with this status. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. | | `archived` | `string` | No | `false` lists conversations that are not archived, `true` only archived ones, `all` both. Default: `false`. One of `false`, `true`, `all`. | | `updated_since` | `timestamp` | No | Only conversations whose `updated_at` is at or after this time. ISO 8601; a time without an offset is read as UTC (URL-encode `+` as `%2B`). Leave it out to scan from the first conversation. | | `limit` | `integer` | No | Conversations per page. 1 to 100. Default: `50`. | | `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. It carries the filters: send it alone, or with exactly the filters of the first page. Up to 4,096 characters. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `data` | `array of objects` | Conversations, oldest change first. | | `data.id` | `UUID` | The conversation. | | `data.campaign_id` | `UUID` | The conversation's campaign. | | `data.domain` | `string` | Which inbox it is in: a contact campaign's (`contact`) or a real-estate offer campaign's (`realestate`). One of `contact`, `realestate`. | | `data.contact_id` | `UUID \| null` | The CRM contact, or `null` when it is not linked to one. | | `data.subject` | `string \| null` | The thread subject. | | `data.participant_email` | `string \| null` | The other party's address, following the member's email visibility. | | `data.status` | `string` | The conversation status shown in the inbox. Archiving sets `closed` and restoring sets `open`; a `suppressed` conversation stays `suppressed`. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. | | `data.ai_status` | `string` | Where the AI email agent is with the thread, independent of `status`. Archiving sets `disabled`. One of `pending`, `queued`, `drafted`, `sent`, `auto_replied`, `rejected`, `needs_review`, `disabled`. | | `data.updated_at` | `timestamp` | When the conversation last changed, read and archive changes included (RFC 3339, UTC). | | `data.last_message_at` | `timestamp \| null` | Time of its latest message. | | `data.last_inbound_at` | `timestamp \| null` | Time of its latest inbound message. | | `data.read` | `boolean` | `true` when it was read at or after its latest inbound message. A conversation without inbound messages is read. | | `data.archived` | `boolean` | `true` while it is archived. | | `data.reply_identities` | `object` | The two senders a reply can use (its `send_as`), as they are now. A preview, not a reservation: the sender is checked again when you reply. | | `data.reply_identities.campaign_mailbox` | `object` | The campaign mailbox the thread came from: the mailbox or account that sent the email the contact answered. | | `data.reply_identities.campaign_mailbox.email` | `string \| null` | The sender address; `null` when none is set up. | | `data.reply_identities.campaign_mailbox.available` | `boolean` | Whether a reply can be sent from it now. | | `data.reply_identities.campaign_mailbox.reason` | `string` | Only present when `available` is `false`: `not_configured` (no usable sender is set up) or `reconnect_required` (the account or mailbox must be reconnected in DialEcho). One of `not_configured`, `reconnect_required`. | | `data.reply_identities.reply_address` | `object` | The campaign's connected reply address, set in the campaign's email settings. On a real-estate conversation it is available only when the thread is already on that connected account. | | `data.reply_identities.reply_address.email` | `string \| null` | The sender address; `null` when none is set up. | | `data.reply_identities.reply_address.available` | `boolean` | Whether a reply can be sent from it now. | | `data.reply_identities.reply_address.reason` | `string` | Only present when `available` is `false`: `not_configured` (no usable sender is set up) or `reconnect_required` (the account or mailbox must be reconnected in DialEcho). One of `not_configured`, `reconnect_required`. | | `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when the window is walked. | | `has_more` | `boolean` | `true` while `next_cursor` is set. | | `scan_through` | `timestamp` | The end of this scan's window (RFC 3339, UTC). Start the next scan from it, minus 5 minutes. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/inbox/conversations?campaign_id=cccccccc-0000-4000-8000-000000000001&updated_since=2026-11-02T00:00:00Z&limit=50" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/inbox/conversations?campaign_id=cccccccc-0000-4000-8000-000000000001&updated_since=2026-11-02T00:00:00Z&limit=50", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "data": [ { "id": "44444444-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "domain": "contact", "contact_id": "bbbbbbbb-0000-4000-8000-000000000001", "subject": "Your solar quote", "participant_email": "pat@example.com", "status": "open", "ai_status": "needs_review", "updated_at": "2026-11-02T16:20:02Z", "last_message_at": "2026-11-02T16:20:00Z", "last_inbound_at": "2026-11-02T16:20:00Z", "read": false, "archived": false, "reply_identities": { "campaign_mailbox": { "email": "jordan@outreach.example.com", "available": true }, "reply_address": { "email": "jordan@example.com", "available": true } } } ], "next_cursor": null, "has_more": false, "scan_through": "2026-11-02T16:21:00Z" } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `inbox:read`. | | 404 | `not_found` | `campaign_id` is not a campaign whose inbox the key can read, or `domain` names an inbox this DialEcho deployment does not run. | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor. | | 422 | `invalid_cursor` | The cursor was not issued to this key for this list, was changed, or was sent with different filters. | | 422 | `invalid_request` | A parameter is invalid. `error.fields` names the first invalid filter (`campaign_id`, `domain`, `status`, `archived`, `updated_since`), or `query.limit` or `query.cursor`. | | 503 | `dependency_unavailable` | The inbox or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation) - `GET /v1/inbox/inbound-messages` · [List inbound messages](/api/inbox/list-inbound) - `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation) --- # Retrieve a conversation `GET /v1/inbox/conversations/{conversation_id}` Returns a conversation with its messages, oldest first. **Scope:** `inbox:read` **Access:** The key's member must be able to use the campaign's email inbox in DialEcho (its message and email permissions) and see the contact; other conversations are left out. `messages` are the thread's stored inbound and outbound emails (drafts are not included), ordered by `created_at` (then `id`), oldest first, up to `limit` at a time. When `has_more` is `true`, ask for the next page with `messages_cursor` set to `next_messages_cursor`. The first page fixes the window, so a message that arrives while you page waits for your next read. Every page returns the conversation again. When the thread was opened by an email blast or a real-estate offer that is not itself a stored message, that first email is returned separately as `origin_message` (`is_synthetic: true`), so you can see what the contact answered. Its `id` is not a UUID (`blast-job-` or `offer-send-`) and it cannot be replied to. `origin_status` says whether there is one: `available`; `not_applicable` when the thread has no such origin or the first email is already in `messages`; or `unavailable` when it could not be loaded (the rest of the response is still complete). The conversation must be one the key's member can see, otherwise `404`. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `conversation_id` | `UUID` | Yes | The conversation. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | `integer` | No | Messages per page. 1 to 100. Default: `100`. | | `messages_cursor` | `string` | No | The `next_messages_cursor` of the previous page of this conversation, unchanged. Up to 4,096 characters. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `conversation` | `object` | The conversation. | | `conversation.id` | `UUID` | The conversation. | | `conversation.campaign_id` | `UUID` | The conversation's campaign. | | `conversation.domain` | `string` | Which inbox it is in: a contact campaign's (`contact`) or a real-estate offer campaign's (`realestate`). One of `contact`, `realestate`. | | `conversation.contact_id` | `UUID \| null` | The CRM contact, or `null` when it is not linked to one. | | `conversation.subject` | `string \| null` | The thread subject. | | `conversation.participant_email` | `string \| null` | The other party's address, following the member's email visibility. | | `conversation.status` | `string` | The conversation status shown in the inbox. Archiving sets `closed` and restoring sets `open`; a `suppressed` conversation stays `suppressed`. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. | | `conversation.ai_status` | `string` | Where the AI email agent is with the thread, independent of `status`. Archiving sets `disabled`. One of `pending`, `queued`, `drafted`, `sent`, `auto_replied`, `rejected`, `needs_review`, `disabled`. | | `conversation.updated_at` | `timestamp` | When the conversation last changed, read and archive changes included (RFC 3339, UTC). | | `conversation.last_message_at` | `timestamp \| null` | Time of its latest message. | | `conversation.last_inbound_at` | `timestamp \| null` | Time of its latest inbound message. | | `conversation.read` | `boolean` | `true` when it was read at or after its latest inbound message. A conversation without inbound messages is read. | | `conversation.archived` | `boolean` | `true` while it is archived. | | `conversation.reply_identities` | `object` | The two senders a reply can use (its `send_as`), as they are now. A preview, not a reservation: the sender is checked again when you reply. | | `conversation.reply_identities.campaign_mailbox` | `object` | The campaign mailbox the thread came from: the mailbox or account that sent the email the contact answered. | | `conversation.reply_identities.campaign_mailbox.email` | `string \| null` | The sender address; `null` when none is set up. | | `conversation.reply_identities.campaign_mailbox.available` | `boolean` | Whether a reply can be sent from it now. | | `conversation.reply_identities.campaign_mailbox.reason` | `string` | Only present when `available` is `false`: `not_configured` (no usable sender is set up) or `reconnect_required` (the account or mailbox must be reconnected in DialEcho). One of `not_configured`, `reconnect_required`. | | `conversation.reply_identities.reply_address` | `object` | The campaign's connected reply address, set in the campaign's email settings. On a real-estate conversation it is available only when the thread is already on that connected account. | | `conversation.reply_identities.reply_address.email` | `string \| null` | The sender address; `null` when none is set up. | | `conversation.reply_identities.reply_address.available` | `boolean` | Whether a reply can be sent from it now. | | `conversation.reply_identities.reply_address.reason` | `string` | Only present when `available` is `false`: `not_configured` (no usable sender is set up) or `reconnect_required` (the account or mailbox must be reconnected in DialEcho). One of `not_configured`, `reconnect_required`. | | `messages` | `array of objects` | This page of messages, oldest first. | | `messages.id` | `UUID` | The message. Pass an inbound message's `id` as `in_reply_to_message_id` to answer it. | | `messages.conversation_id` | `UUID` | Its conversation. | | `messages.campaign_id` | `UUID` | The conversation's campaign. | | `messages.domain` | `string` | The conversation's inbox. One of `contact`, `realestate`. | | `messages.direction` | `string` | `inbound` from the contact, `outbound` to them. One of `inbound`, `outbound`. | | `messages.kind` | `string \| null` | `human_reply`: a person wrote back. `auto_reply`: an auto-responder, such as an out-of-office. `manual_reply`: sent by your team or through this API. `ai_reply`: sent by the AI email agent. `null` when the message was never classified, as with older messages and `origin_message`. One of `human_reply`, `auto_reply`, `manual_reply`, `ai_reply`. | | `messages.from_email` | `string \| null` | The sender, following the member's email visibility. | | `messages.to_emails` | `array of strings` | The recipients. Addresses the member's role hides are left out. | | `messages.cc_emails` | `array of strings` | The CC recipients, the same way. | | `messages.subject` | `string \| null` | The subject. | | `messages.text` | `string \| null` | The plain-text body. `Restricted` when the member's role masks messages. | | `messages.html` | `string \| null` | The HTML body exactly as stored, or `null` (also when the role masks messages). Sanitize it before you display it and never execute it. | | `messages.attachments` | `array of objects` | What was attached; there are no download links. Empty when the role masks messages. A detail that is not known is `null`. | | `messages.attachments.filename` | `string \| null` | The file name. | | `messages.attachments.content_type` | `string \| null` | The MIME type, for example `application/pdf`. | | `messages.attachments.size_bytes` | `integer \| null` | The size in bytes. | | `messages.created_at` | `timestamp \| null` | When DialEcho stored the message (RFC 3339, UTC). The inbound feed is ordered by it. | | `messages.received_at` | `timestamp \| null` | When the mail provider received an inbound message. | | `messages.sent_at` | `timestamp \| null` | When an outbound message was sent. | | `messages.is_synthetic` | `boolean` | `true` only for a conversation's `origin_message`, which is not a stored message. | | `messages.external_reply` | `object \| null` | Set on replies sent through this API; `null` on every other message. | | `messages.external_reply.api_key_id` | `UUID \| null` | The API key that sent it. | | `messages.external_reply.organization_member_id` | `UUID \| null` | The key's team member. | | `messages.external_reply.automated` | `boolean` | Always `true`: sent by an integration, not typed by a person. | | `messages.external_reply.in_reply_to_message_id` | `UUID \| null` | The inbound message it answered. | | `origin_message` | `object \| null` | The email that opened the thread when it is not a stored message, with the same attributes as a message and `is_synthetic: true`; otherwise `null`. | | `origin_status` | `string` | Whether there is an `origin_message`. One of `available`, `not_applicable`, `unavailable`. | | `next_messages_cursor` | `string \| null` | Pass it back as `messages_cursor` for the next page of messages; `null` when there is none. | | `has_more` | `boolean` | `true` when more messages follow. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000001?limit=100" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000001?limit=100", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "conversation": { "id": "44444444-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "domain": "contact", "contact_id": "bbbbbbbb-0000-4000-8000-000000000001", "subject": "Your solar quote", "participant_email": "pat@example.com", "status": "open", "ai_status": "needs_review", "updated_at": "2026-11-02T16:20:02Z", "last_message_at": "2026-11-02T16:20:00Z", "last_inbound_at": "2026-11-02T16:20:00Z", "read": false, "archived": false, "reply_identities": { "campaign_mailbox": { "email": "jordan@outreach.example.com", "available": true }, "reply_address": { "email": "jordan@example.com", "available": true } } }, "messages": [ { "id": "66666666-0000-4000-8000-000000000001", "conversation_id": "44444444-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "domain": "contact", "direction": "outbound", "kind": "ai_reply", "from_email": "jordan@outreach.example.com", "to_emails": ["pat@example.com"], "cc_emails": [], "subject": "Re: Your solar quote", "text": "Happy to help. Which day works for a call this week?", "html": "

Happy to help. Which day works for a call this week?

", "attachments": [], "created_at": "2026-11-02T15:02:11Z", "received_at": null, "sent_at": "2026-11-02T15:02:10Z", "is_synthetic": false, "external_reply": null }, { "id": "66666666-0000-4000-8000-000000000002", "conversation_id": "44444444-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "domain": "contact", "direction": "inbound", "kind": "human_reply", "from_email": "pat@example.com", "to_emails": ["jordan@outreach.example.com"], "cc_emails": [], "subject": "Re: Your solar quote", "text": "Is Thursday afternoon available?", "html": "

Is Thursday afternoon available?

", "attachments": [{ "filename": "electric-bill.pdf", "content_type": "application/pdf", "size_bytes": 48213 }], "created_at": "2026-11-02T16:20:02Z", "received_at": "2026-11-02T16:20:00Z", "sent_at": null, "is_synthetic": false, "external_reply": null } ], "origin_message": null, "origin_status": "not_applicable", "next_messages_cursor": null, "has_more": false } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `inbox:read`. | | 404 | `not_found` | The conversation does not exist, was deleted, or the key's member cannot see it. | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor. | | 422 | `invalid_cursor` | `messages_cursor` was not issued to this key for this conversation, or was changed. | | 422 | `invalid_request` | `conversation_id` is not a UUID, `limit` is outside 1 to 100, or `messages_cursor` is over 4,096 characters. | | 503 | `dependency_unavailable` | The inbox or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/inbox/conversations` · [List conversations](/api/inbox/list-conversations) - `POST /v1/inbox/conversations/{conversation_id}/replies` · [Reply to a conversation](/api/inbox/reply) - `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation) --- # List inbound messages `GET /v1/inbox/inbound-messages` A polling feed of new inbound emails, each with its conversation's state. **Scope:** `inbox:read` **Access:** The key's member must be able to use the campaign's email inbox in DialEcho (its message and email permissions) and see the contact; other conversations are left out. Poll it to act on replies as they arrive. Messages are ordered by when DialEcho stored them (`created_at`, then `id`), not by the provider's `received_at`, so a message that syncs late still comes through. Every inbound kind is included, auto-replies too: check `kind`. Marking a conversation read or archived does not take its messages out of the feed; archived threads still appear, with `conversation_state.archived` set. `next_cursor` is always set, even on an empty page. While `has_more` is `true`, ask for the next page right away. When it is `false` the current window is walked: save `next_cursor` and poll with it again in about 10 seconds, with jitter. Store each page's messages before you save its cursor, so a crash only repeats a page. Delivery is at least once. Each new window starts 5 minutes before the previous one ended (never before `since`), so recent messages come again, and a repeated page repeats its messages: deduplicate by message `id`, never by sender, subject or time. The feed follows timestamps; it is not a change log, so a message stored late (a slow import, or access granted later) can land behind your position. Run a second, slower walk at least daily with its own cursor, starting from your original `since` (or `1970-01-01T00:00:00Z` for all history), and deduplicate ids across both walks. Only messages of conversations the key's member can see are returned. Each request looks at up to 1,000 messages, so a page can be empty while `has_more` is `true`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | No | Only this campaign. It must be a campaign whose inbox the key can read, otherwise `404`. | | `domain` | `string` | No | Only this inbox. Leave it out for both. One of `contact`, `realestate`. | | `since` | `timestamp` | No | Where the feed starts: messages stored at or after this time. Only for the first request; after that the cursor carries it. ISO 8601; a time without an offset is read as UTC (URL-encode `+` as `%2B`). Default: when the API key was created. | | `limit` | `integer` | No | Messages per page. 1 to 100. Default: `50`. | | `cursor` | `string` | No | The `next_cursor` you saved, unchanged. It carries `campaign_id`, `domain` and `since`: send it alone, or with exactly the same filters. Up to 4,096 characters. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `data` | `array of objects` | Inbound messages, in the order DialEcho stored them. | | `data.message` | `object` | The inbound message. | | `data.message.id` | `UUID` | The message. Pass an inbound message's `id` as `in_reply_to_message_id` to answer it. | | `data.message.conversation_id` | `UUID` | Its conversation. | | `data.message.campaign_id` | `UUID` | The conversation's campaign. | | `data.message.domain` | `string` | The conversation's inbox. One of `contact`, `realestate`. | | `data.message.direction` | `string` | `inbound` from the contact, `outbound` to them. One of `inbound`, `outbound`. | | `data.message.kind` | `string \| null` | `human_reply`: a person wrote back. `auto_reply`: an auto-responder, such as an out-of-office. `manual_reply`: sent by your team or through this API. `ai_reply`: sent by the AI email agent. `null` when the message was never classified, as with older messages and `origin_message`. One of `human_reply`, `auto_reply`, `manual_reply`, `ai_reply`. | | `data.message.from_email` | `string \| null` | The sender, following the member's email visibility. | | `data.message.to_emails` | `array of strings` | The recipients. Addresses the member's role hides are left out. | | `data.message.cc_emails` | `array of strings` | The CC recipients, the same way. | | `data.message.subject` | `string \| null` | The subject. | | `data.message.text` | `string \| null` | The plain-text body. `Restricted` when the member's role masks messages. | | `data.message.html` | `string \| null` | The HTML body exactly as stored, or `null` (also when the role masks messages). Sanitize it before you display it and never execute it. | | `data.message.attachments` | `array of objects` | What was attached; there are no download links. Empty when the role masks messages. A detail that is not known is `null`. | | `data.message.attachments.filename` | `string \| null` | The file name. | | `data.message.attachments.content_type` | `string \| null` | The MIME type, for example `application/pdf`. | | `data.message.attachments.size_bytes` | `integer \| null` | The size in bytes. | | `data.message.created_at` | `timestamp \| null` | When DialEcho stored the message (RFC 3339, UTC). The inbound feed is ordered by it. | | `data.message.received_at` | `timestamp \| null` | When the mail provider received an inbound message. | | `data.message.sent_at` | `timestamp \| null` | When an outbound message was sent. | | `data.message.is_synthetic` | `boolean` | `true` only for a conversation's `origin_message`, which is not a stored message. | | `data.message.external_reply` | `object \| null` | Set on replies sent through this API; `null` on every other message. | | `data.message.external_reply.api_key_id` | `UUID \| null` | The API key that sent it. | | `data.message.external_reply.organization_member_id` | `UUID \| null` | The key's team member. | | `data.message.external_reply.automated` | `boolean` | Always `true`: sent by an integration, not typed by a person. | | `data.message.external_reply.in_reply_to_message_id` | `UUID \| null` | The inbound message it answered. | | `data.conversation_state` | `object` | Its conversation as it is now. Read the whole conversation with `GET /v1/inbox/conversations/{conversation_id}`. | | `data.conversation_state.status` | `string` | The conversation status. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. | | `data.conversation_state.archived` | `boolean` | `true` while it is archived. | | `data.conversation_state.read` | `boolean` | `true` when it was read at or after its latest inbound message. | | `next_cursor` | `string` | Always set: pass it back as `cursor` for the next page or the next poll. | | `has_more` | `boolean` | `true` while the current window holds more messages: ask again right away. `false`: poll again later. | | `scan_through` | `timestamp` | The end of the current window (RFC 3339, UTC). | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/inbox/inbound-messages?campaign_id=cccccccc-0000-4000-8000-000000000001&since=2026-11-02T00:00:00Z&limit=50" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/inbox/inbound-messages?campaign_id=cccccccc-0000-4000-8000-000000000001&since=2026-11-02T00:00:00Z&limit=50", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "data": [ { "message": { "id": "66666666-0000-4000-8000-000000000002", "conversation_id": "44444444-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "domain": "contact", "direction": "inbound", "kind": "human_reply", "from_email": "pat@example.com", "to_emails": ["jordan@outreach.example.com"], "cc_emails": [], "subject": "Re: Your solar quote", "text": "Is Thursday afternoon available?", "html": "

Is Thursday afternoon available?

", "attachments": [{ "filename": "electric-bill.pdf", "content_type": "application/pdf", "size_bytes": 48213 }], "created_at": "2026-11-02T16:20:02Z", "received_at": "2026-11-02T16:20:00Z", "sent_at": null, "is_synthetic": false, "external_reply": null }, "conversation_state": { "status": "open", "archived": false, "read": false } } ], "next_cursor": "v2.exampleOpaqueCursor", "has_more": false, "scan_through": "2026-11-02T16:21:00Z" } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `inbox:read`. | | 404 | `not_found` | `campaign_id` is not a campaign whose inbox the key can read, or `domain` names an inbox this DialEcho deployment does not run. | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor, `since` your last checkpoint, and restart the daily walk too. | | 422 | `invalid_cursor` | The cursor was not issued to this key for this feed, was changed, or was sent with different filters. | | 422 | `invalid_request` | A parameter is invalid. `error.fields` names the first invalid filter (`campaign_id`, `domain`, `since`), or `query.limit` or `query.cursor`. | | 503 | `dependency_unavailable` | The inbox or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `POST /v1/inbox/conversations/{conversation_id}/replies` · [Reply to a conversation](/api/inbox/reply) - `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation) - `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation) --- # Reply to a conversation `POST /v1/inbox/conversations/{conversation_id}/replies` Emails a reply to the conversation's latest inbound message, from the campaign mailbox or the reply address. **Scope:** `inbox:reply` **Access:** The key's member must be able to see the conversation in the inbox. DialEcho sends the reply the way your team's replies from the inbox go out: the recipient, subject and threading come from the conversation, and without `html` the HTML part is made from `text`. Recipients, sender, subject, CC, BCC and attachments cannot be set; any field not listed here is rejected. `200` means the mail provider accepted the reply and DialEcho stored it, not that it reached the inbox. It shows in the thread as a `manual_reply` with `external_reply` set. `in_reply_to_message_id` must be the conversation's latest inbound message, from the feed or the conversation. If a newer one has arrived the reply is refused with `409 conversation_changed`: read the conversation again and answer the latest. Each inbound message gets at most one reply through the API, across all your keys; a message your team or the AI agent already answered is `409 already_replied` too. A pending AI reply or draft for the message is cancelled when yours is sent; on real-estate conversations a draft waiting for review is refused with `409 draft_requires_review` instead. `send_as` picks the sender; check the conversation's `reply_identities` first. `campaign_mailbox` sends from the mailbox the thread came from. `reply_address` sends from the campaign's connected reply address; when it is not set up, disconnected or out of capacity the reply is refused with `409 sender_unavailable` and never sent from another address. `Idempotency-Key` is required and belongs to your API key and this conversation. The same key with the same body returns `200` with the original message and `replayed: true` and sends nothing, even after the conversation was archived or answered. The same key with a different body is `409 idempotency_conflict`. Bodies are compared after `text` is trimmed and a blank `html` dropped. After a timeout or `409 delivery_unknown`, retry only with the same `Idempotency-Key` and body: it reports the outcome and never sends a second email. `delivery_unknown` means the reply may have gone out, so never send it again under a new key. To change the conversation's state after replying, use `PATCH`; never resend an email to fix state. Replies have their own limit on top of the request limit: 20 a minute per API key, in bursts of up to 5. Requests rejected as invalid (`422`) do not count; every other reply request does, replays included. ## Headers | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | `string` | Yes | Identifies this reply for your API key and this conversation. Reusing it with a different body is `409 idempotency_conflict`. 16 to 128 printable ASCII characters, no spaces (a UUID works). | ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `conversation_id` | `UUID` | Yes | The conversation. | ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `text` | `string` | Yes | The reply as plain text. Leading and trailing whitespace is trimmed, and it must contain more than whitespace. Up to 50,000 characters, counted before trimming. | | `html` | `string` | No | An HTML version of the same reply. Blank is the same as leaving it out. Up to 200,000 characters. | | `send_as` | `string` | Yes | Which sender to reply from. One of `campaign_mailbox`, `reply_address`. | | `in_reply_to_message_id` | `UUID` | Yes | The inbound message you are answering: the conversation's latest inbound message. | ## Response **Status:** `200 OK` The same for a new reply and a replay; `replayed` tells them apart. | Name | Type | Description | | --- | --- | --- | | `status` | `string` | Always `sent`. One of `sent`. | | `replayed` | `boolean` | `true` when this answers an earlier request with the same `Idempotency-Key`; nothing was sent this time. | | `message` | `object` | The reply as stored: `direction` `outbound`, `kind` `manual_reply`, `external_reply` set. | | `message.id` | `UUID` | The message. Pass an inbound message's `id` as `in_reply_to_message_id` to answer it. | | `message.conversation_id` | `UUID` | Its conversation. | | `message.campaign_id` | `UUID` | The conversation's campaign. | | `message.domain` | `string` | The conversation's inbox. One of `contact`, `realestate`. | | `message.direction` | `string` | `inbound` from the contact, `outbound` to them. One of `inbound`, `outbound`. | | `message.kind` | `string \| null` | `human_reply`: a person wrote back. `auto_reply`: an auto-responder, such as an out-of-office. `manual_reply`: sent by your team or through this API. `ai_reply`: sent by the AI email agent. `null` when the message was never classified, as with older messages and `origin_message`. One of `human_reply`, `auto_reply`, `manual_reply`, `ai_reply`. | | `message.from_email` | `string \| null` | The sender, following the member's email visibility. | | `message.to_emails` | `array of strings` | The recipients. Addresses the member's role hides are left out. | | `message.cc_emails` | `array of strings` | The CC recipients, the same way. | | `message.subject` | `string \| null` | The subject. | | `message.text` | `string \| null` | The plain-text body. `Restricted` when the member's role masks messages. | | `message.html` | `string \| null` | The HTML body exactly as stored, or `null` (also when the role masks messages). Sanitize it before you display it and never execute it. | | `message.attachments` | `array of objects` | What was attached; there are no download links. Empty when the role masks messages. A detail that is not known is `null`. | | `message.attachments.filename` | `string \| null` | The file name. | | `message.attachments.content_type` | `string \| null` | The MIME type, for example `application/pdf`. | | `message.attachments.size_bytes` | `integer \| null` | The size in bytes. | | `message.created_at` | `timestamp \| null` | When DialEcho stored the message (RFC 3339, UTC). The inbound feed is ordered by it. | | `message.received_at` | `timestamp \| null` | When the mail provider received an inbound message. | | `message.sent_at` | `timestamp \| null` | When an outbound message was sent. | | `message.is_synthetic` | `boolean` | `true` only for a conversation's `origin_message`, which is not a stored message. | | `message.external_reply` | `object \| null` | Set on replies sent through this API; `null` on every other message. | | `message.external_reply.api_key_id` | `UUID \| null` | The API key that sent it. | | `message.external_reply.organization_member_id` | `UUID \| null` | The key's team member. | | `message.external_reply.automated` | `boolean` | Always `true`: sent by an integration, not typed by a person. | | `message.external_reply.in_reply_to_message_id` | `UUID \| null` | The inbound message it answered. | ## Example request **curl** ```bash curl -X POST "https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000001/replies" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 0f0f0f0f-0000-4000-8000-00000000abcd" \ -d '{ "text": "Thursday at 2 pm works. Talk then!", "html": "

Thursday at 2 pm works. Talk then!

", "send_as": "reply_address", "in_reply_to_message_id": "66666666-0000-4000-8000-000000000002" }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000001/replies", { method: "POST", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", "Idempotency-Key": "0f0f0f0f-0000-4000-8000-00000000abcd", }, body: JSON.stringify({ "text": "Thursday at 2 pm works. Talk then!", "html": "

Thursday at 2 pm works. Talk then!

", "send_as": "reply_address", "in_reply_to_message_id": "66666666-0000-4000-8000-000000000002" }), }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "status": "sent", "replayed": false, "message": { "id": "88888888-0000-4000-8000-000000000001", "conversation_id": "44444444-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "domain": "contact", "direction": "outbound", "kind": "manual_reply", "from_email": "jordan@example.com", "to_emails": ["pat@example.com"], "cc_emails": [], "subject": "Re: Your solar quote", "text": "Thursday at 2 pm works. Talk then!", "html": "

Thursday at 2 pm works. Talk then!

", "attachments": [], "created_at": "2026-11-02T16:22:04Z", "received_at": null, "sent_at": "2026-11-02T16:22:03Z", "is_synthetic": false, "external_reply": { "api_key_id": "abababab-0000-4000-8000-000000000001", "organization_member_id": "ffffffff-0000-4000-8000-000000000001", "automated": true, "in_reply_to_message_id": "66666666-0000-4000-8000-000000000002" } } } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `inbox:reply`. Checked before the body is validated. | | 404 | `not_found` | The conversation does not exist, was deleted, or the key's member cannot see it; or `in_reply_to_message_id` is not a message of this conversation. | | 409 | `idempotency_conflict` | The `Idempotency-Key` was already used on this conversation with a different body. Nothing was sent. | | 409 | `conversation_changed` | `in_reply_to_message_id` is not the latest inbound message any more, or the conversation changed while the reply was prepared. Read it again and answer the latest. | | 409 | `already_replied` | The message was already answered, by your team, the AI agent or another API reply. Do not resend. | | 409 | `reply_in_progress` | Another reply to the conversation is being written or sent right now. `retryable` is `true`: retry shortly with the same `Idempotency-Key`. | | 409 | `delivery_unknown` | The reply may have been sent but the provider did not confirm it. Retry only with the same `Idempotency-Key` and body, never as a new request. | | 409 | `conversation_archived` | The conversation is archived. Restore it with `PATCH` and `{"archived": false}` first. | | 409 | `recipient_suppressed` | The recipient unsubscribed or is suppressed. Do not retry. | | 409 | `non_actionable_inbound` | The message cannot be answered automatically: it is an auto-reply or an opt-out, or the conversation has no recipient address. | | 409 | `sender_unavailable` | The `send_as` sender is not set up, disconnected or out of capacity, or an earlier API reply to this message failed before it was sent and must be reviewed in DialEcho. Nothing was sent. | | 409 | `draft_requires_review` | Real-estate conversations: an AI draft for this message is waiting for review in DialEcho. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing was sent. | | 422 | `invalid_request` | `conversation_id` is not a UUID, the `Idempotency-Key` is missing or malformed, the body is invalid (`error.fields` lists every problem), or `in_reply_to_message_id` is not an inbound message. | | 429 | `rate_limited` | Over the reply limit (20 a minute per key, bursts of 5) or the request limit. Wait the `Retry-After` seconds, then retry with the same `Idempotency-Key`. | | 503 | `dependency_unavailable` | The suppression check, the inbox or the access check was unavailable before anything was sent. Retry with backoff and the same `Idempotency-Key`. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/inbox/inbound-messages` · [List inbound messages](/api/inbox/list-inbound) - `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation) - `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation) --- # Update a conversation `PATCH /v1/inbox/conversations/{conversation_id}` Marks a conversation read or unread, or archives or restores it. **Scope:** `inbox:reply` **Access:** The key's member must be able to see the conversation in the inbox. Send exactly one of `read` or `archived` per request; to change both, send two requests. It is the same change your team makes in the inbox, and it never takes messages out of the inbound feed. There is no delete. Archiving closes the conversation (`status` becomes `closed` unless it is `suppressed`), marks it read, turns the AI agent off for it (`ai_status` becomes `disabled`) and cancels its pending AI replies. Replies are refused while it is archived. Restoring (`archived: false`) sets `status` back to `open` (a `suppressed` conversation stays `suppressed`); it does not turn the AI agent back on and sends nothing. Repeating a request is safe. If a `PATCH` after a reply fails, retry the `PATCH`; never resend the email to fix the conversation's state. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `conversation_id` | `UUID` | Yes | The conversation. | ## Body **Content type:** `application/json` | Name | Type | Required | Description | | --- | --- | --- | --- | | `read` | `boolean` | No | `true` marks it read, `false` unread. Send exactly one of `read` or `archived`. A JSON boolean; strings and numbers are rejected. | | `archived` | `boolean` | No | `true` archives it, `false` restores it. Send exactly one of `read` or `archived`. A JSON boolean; strings and numbers are rejected. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `id` | `UUID` | The conversation. | | `read` | `boolean` | Whether it is read now. | | `archived` | `boolean` | Whether it is archived now. | | `status` | `string` | Its status now. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. | | `updated_at` | `timestamp` | When it last changed (RFC 3339, UTC). | ## Example request **curl** ```bash curl -X PATCH "https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000002" \ -H "Authorization: Bearer dei_your_key" \ -H "Content-Type: application/json" \ -d '{ "archived": true }' ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/inbox/conversations/44444444-0000-4000-8000-000000000002", { method: "PATCH", headers: { "Authorization": "Bearer dei_your_key", "Content-Type": "application/json", }, body: JSON.stringify({ "archived": true }), }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "id": "44444444-0000-4000-8000-000000000002", "read": true, "archived": true, "status": "closed", "updated_at": "2026-11-02T16:25:41Z" } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `inbox:reply`. Checked before the body is validated. | | 404 | `not_found` | The conversation does not exist, was deleted, or the key's member cannot see it. | | 409 | `reply_in_progress` | Archiving: a reply to the conversation is being sent right now. `retryable` is `true`: retry shortly. | | 413 | `payload_too_large` | The body is larger than 1 MB. It was not read and nothing changed. | | 422 | `invalid_request` | `conversation_id` is not a UUID, or the body is not exactly one of `read` or `archived` as a boolean. `error.fields` names `body` (none or both sent), the field that is not a boolean, or an unknown field. | | 503 | `dependency_unavailable` | The inbox or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation) - `POST /v1/inbox/conversations/{conversation_id}/replies` · [Reply to a conversation](/api/inbox/reply) - `GET /v1/inbox/conversations` · [List conversations](/api/inbox/list-conversations) --- # Blasts Read-only results of your campaigns' email blasts: how many recipients were delivered, bounced, replied or opted out, day by day, and which addresses got which blast. All four endpoints need `blasts:read`, and nothing here changes a blast. A blast is readable when its campaign is one the key may reach and the key's member can use email blasts there in DialEcho (the campaign's leads, with email and message permissions). Any other blast or campaign is reported as `404`, never `403`. Send rows are not narrowed by contact assignment. Send rows follow the member's contact field visibility in the send's campaign: `email` and `from_email` are `null` when the role hides email and masked (`p***@example.net`) when it masks it, `subject` is `Restricted` when it masks messages, and `bounce_diagnostic` follows the stricter of the two. An `email` filter searches only campaigns where the member sees addresses in full; elsewhere it matches nothing (an empty page, never `403`), so it cannot confirm an address the member cannot see. Blast stats and `subject_template` are not affected. Stats are exact counts of each blast's recipients as stored now, so they keep moving while a blast sends and as bounces and replies arrive. ## Endpoints ### [List blasts](/api/blasts/list) `GET /v1/blasts` Returns your campaigns' email blasts with their main stats, newest first. ### [Retrieve a blast](/api/blasts/get) `GET /v1/blasts/{blast_id}` Returns one blast with its full stats and a day-by-day breakdown. ### [List a blast's sends](/api/blasts/list-sends) `GET /v1/blasts/{blast_id}/sends` Returns one row per recipient of a blast, the most recently added first. ### [Look up sends](/api/blasts/lookup-sends) `GET /v1/sends` Lists every blast email sent to one address, oldest first. --- # List blasts `GET /v1/blasts` Returns your campaigns' email blasts with their main stats, newest first. **Scope:** `blasts:read` Blasts are ordered by `created_at` (then `id`), newest first, and rows are never repeated or skipped. The first page fixes the window: a blast created while you page does not join the walk; it heads the next list you start without a cursor. List rows carry the main stats. `opened`, `clicked` and the day-by-day breakdown are only on `GET /v1/blasts/{blast_id}`. Without `campaign_id` the list covers every campaign whose blasts the key can read; a `campaign_id` must be one of them, otherwise `404`. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `campaign_id` | `UUID` | No | Only this campaign. | | `status` | `string` | No | Only blasts with this status. One of `draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`. | | `since` | `timestamp` | No | Only blasts created at or after this time. ISO 8601; a time without an offset is read as UTC (URL-encode `+` as `%2B`). | | `limit` | `integer` | No | Blasts per page. Every blast's stats are counted on each request, so pages are small. 1 to 20. Default: `20`. | | `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. It carries the filters: send it alone, or with exactly the filters of the first page. Up to 4,096 characters. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `data` | `array of objects` | Blasts, newest first. | | `data.id` | `UUID` | The blast. | | `data.name` | `string` | Its name in DialEcho. | | `data.campaign_id` | `UUID` | The blast's campaign. | | `data.status` | `string` | The blast status. One of `draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`. | | `data.created_at` | `timestamp` | When it was created (RFC 3339, UTC). | | `data.scheduled_at` | `timestamp \| null` | When it is or was scheduled to start; `null` when it was not scheduled. | | `data.subject_template` | `string \| null` | The subject as written, merge tags included. Each send's `subject` is the subject its recipient got. | | `data.stats` | `object` | The main stats. | | `data.stats.recipients_total` | `integer` | Every recipient of the blast, in any status. | | `data.stats.delivered` | `integer` | Delivered sends: status `delivered`, plus `sent` through any sender but SendGrid, whose sends count once SendGrid confirms delivery. | | `data.stats.bounced` | `integer` | Sends that bounced. | | `data.stats.failed` | `integer` | Sends that failed. | | `data.stats.suppressed` | `integer` | Recipients not emailed because the address is suppressed. | | `data.stats.pending` | `integer` | Recipients not sent yet (`pending` or `processing`). Cancelled and skipped recipients count only in `recipients_total`. | | `data.stats.replied` | `integer` | Delivered recipients who replied: a reply DialEcho matched to their send. | | `data.stats.opt_outs` | `integer` | Recipients who opted out: their matched reply was an opt-out, or the provider reported an unsubscribe. Unsubscribe-link clicks cannot be tied to a blast and are not counted. | | `data.stats.bounce_rate` | `number \| null` | `bounced / (delivered + bounced)`, rounded to 4 decimals; `null` while both are 0. | | `data.stats.reply_rate` | `number \| null` | `replied / delivered`, rounded to 4 decimals and never above 1; `null` while nothing was delivered. | | `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when there is none. | | `has_more` | `boolean` | `true` while `next_cursor` is set. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/blasts?campaign_id=cccccccc-0000-4000-8000-000000000001&limit=20" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/blasts?campaign_id=cccccccc-0000-4000-8000-000000000001&limit=20", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "data": [ { "id": "22222222-0000-4000-8000-000000000001", "name": "Fall re-engagement", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "status": "completed", "created_at": "2026-11-02T14:00:00Z", "scheduled_at": "2026-11-03T15:00:00Z", "subject_template": "{{first_name}}, your solar quote is ready", "stats": { "recipients_total": 1200, "delivered": 1100, "bounced": 30, "failed": 5, "suppressed": 40, "pending": 20, "replied": 25, "opt_outs": 4, "bounce_rate": 0.0265, "reply_rate": 0.0227 } } ], "next_cursor": null, "has_more": false } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `blasts:read`. Checked before the query is validated. | | 404 | `not_found` | `campaign_id` is not a campaign whose blasts the key can read. | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor. | | 422 | `invalid_cursor` | The cursor was not issued to this key for this list, was changed, or was sent with different filters. | | 422 | `invalid_request` | A parameter is invalid. `error.fields` names the first invalid filter (`campaign_id`, `status`, `since`), or `query.limit` (over 20) or `query.cursor`. | | 503 | `dependency_unavailable` | Blast data or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/blasts/{blast_id}` · [Retrieve a blast](/api/blasts/get) - `GET /v1/blasts/{blast_id}/sends` · [List a blast's sends](/api/blasts/list-sends) --- # Retrieve a blast `GET /v1/blasts/{blast_id}` Returns one blast with its full stats and a day-by-day breakdown. **Scope:** `blasts:read` The same blast as the list returns, with `opened` and `clicked` added to `stats` and a `daily` breakdown. Blasts store no start or finish time, so none is returned; `daily` shows when sends, bounces and replies happened. `daily` lists only UTC days with activity, oldest first. Sends are dated by `sent_at`, bounces by when the bounce was recorded and replies by each delivered recipient's first reply. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `blast_id` | `UUID` | Yes | The blast. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `id` | `UUID` | The blast. | | `name` | `string` | Its name in DialEcho. | | `campaign_id` | `UUID` | The blast's campaign. | | `status` | `string` | The blast status. One of `draft`, `scheduled`, `sending`, `paused`, `completed`, `cancelled`, `failed`. | | `created_at` | `timestamp` | When it was created (RFC 3339, UTC). | | `scheduled_at` | `timestamp \| null` | When it is or was scheduled to start; `null` when it was not scheduled. | | `subject_template` | `string \| null` | The subject as written, merge tags included. Each send's `subject` is the subject its recipient got. | | `stats` | `object` | The main stats plus `opened` and `clicked`. | | `stats.recipients_total` | `integer` | Every recipient of the blast, in any status. | | `stats.delivered` | `integer` | Delivered sends: status `delivered`, plus `sent` through any sender but SendGrid, whose sends count once SendGrid confirms delivery. | | `stats.bounced` | `integer` | Sends that bounced. | | `stats.failed` | `integer` | Sends that failed. | | `stats.suppressed` | `integer` | Recipients not emailed because the address is suppressed. | | `stats.pending` | `integer` | Recipients not sent yet (`pending` or `processing`). Cancelled and skipped recipients count only in `recipients_total`. | | `stats.replied` | `integer` | Delivered recipients who replied: a reply DialEcho matched to their send. | | `stats.opt_outs` | `integer` | Recipients who opted out: their matched reply was an opt-out, or the provider reported an unsubscribe. Unsubscribe-link clicks cannot be tied to a blast and are not counted. | | `stats.bounce_rate` | `number \| null` | `bounced / (delivered + bounced)`, rounded to 4 decimals; `null` while both are 0. | | `stats.reply_rate` | `number \| null` | `replied / delivered`, rounded to 4 decimals and never above 1; `null` while nothing was delivered. | | `stats.opened` | `integer \| null` | Delivered or sent recipients who opened it; `null` when the blast did not track opens. | | `stats.clicked` | `integer` | Delivered or sent recipients who clicked a link. | | `daily` | `array of objects` | One row per UTC day with activity, oldest first. | | `daily.date` | `string` | The UTC day, `YYYY-MM-DD`. | | `daily.sends` | `integer` | Sends that went out that day. | | `daily.bounces` | `integer` | Bounces recorded that day. | | `daily.replies` | `integer` | Delivered recipients whose first reply came that day. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/blasts/22222222-0000-4000-8000-000000000001" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/blasts/22222222-0000-4000-8000-000000000001", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "id": "22222222-0000-4000-8000-000000000001", "name": "Fall re-engagement", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "status": "completed", "created_at": "2026-11-02T14:00:00Z", "scheduled_at": "2026-11-03T15:00:00Z", "subject_template": "{{first_name}}, your solar quote is ready", "stats": { "recipients_total": 1200, "delivered": 1100, "bounced": 30, "failed": 5, "suppressed": 40, "pending": 20, "replied": 25, "opt_outs": 4, "bounce_rate": 0.0265, "reply_rate": 0.0227, "opened": null, "clicked": 44 }, "daily": [ { "date": "2026-11-03", "sends": 600, "bounces": 12, "replies": 9 }, { "date": "2026-11-04", "sends": 535, "bounces": 18, "replies": 16 } ] } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `blasts:read`. Checked before the query is validated. | | 404 | `not_found` | The blast does not exist or the key cannot read it: another organization, a campaign outside the key, or one where the key's member cannot use email blasts. | | 422 | `invalid_request` | `blast_id` is not a UUID. | | 503 | `dependency_unavailable` | Blast data or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/blasts` · [List blasts](/api/blasts/list) - `GET /v1/blasts/{blast_id}/sends` · [List a blast's sends](/api/blasts/list-sends) --- # List a blast's sends `GET /v1/blasts/{blast_id}/sends` Returns one row per recipient of a blast, the most recently added first. **Scope:** `blasts:read` Every recipient is listed, sent or not, ordered by when it was added to the blast (then `id`), newest first, inside the window the first page fixes. The order never uses `sent_at`, so a row does not move between pages while the blast sends; each row carries its own `sent_at`. `email` finds one recipient. When the member does not see email addresses in full in the blast's campaign it matches nothing: an empty page, never `403`. The cursor does not store the address, so send the same `email` with every `cursor` of that walk: leaving it out or changing it is `422 invalid_cursor`. Bounced rows carry `bounce_code` and a short `bounce_diagnostic`. Email bodies are never returned. ## Path parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `blast_id` | `UUID` | Yes | The blast. | ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | `string` | No | Only sends with this status. One of `pending`, `processing`, `sent`, `delivered`, `failed`, `bounced`, `suppressed`, `skipped`, `cancelled`. | | `email` | `string` | No | Only the recipient with this address, compared after trimming and lowercasing. | | `since` | `timestamp` | No | Only sends whose `sent_at` is at or after this time, which leaves out recipients not sent yet. ISO 8601; a time without an offset is read as UTC (URL-encode `+` as `%2B`). | | `limit` | `integer` | No | Sends per page. 1 to 500. Default: `100`. | | `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. The cursor does not store the address, so send the same `email` with it. Up to 4,096 characters. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `data` | `array of objects` | Sends, the most recently added first. | | `data.id` | `UUID` | The send: one recipient of one blast. | | `data.blast_id` | `UUID` | Its blast. | | `data.campaign_id` | `UUID` | The blast's campaign. | | `data.email` | `string \| null` | The recipient address; `null` when the member's role hides email, masked (`p***@example.net`) when it masks it. | | `data.lead_id` | `UUID \| null` | The lead it went to, when the recipient is a lead. | | `data.status` | `string` | The send status. `pending` and `processing` have not gone out yet; `suppressed`, `skipped` and `cancelled` never will. One of `pending`, `processing`, `sent`, `delivered`, `failed`, `bounced`, `suppressed`, `skipped`, `cancelled`. | | `data.sent_at` | `timestamp \| null` | When it went out (RFC 3339, UTC); `null` while not sent. | | `data.from_email` | `string \| null` | The mailbox it was sent from, with the same email visibility as `email`. | | `data.subject` | `string \| null` | The subject as the recipient got it; `Restricted` when the member's role masks messages. | | `data.bounce_code` | `string \| null` | Only on `bounced` sends: the bounce code, for example `5.1.1`. | | `data.bounce_diagnostic` | `string \| null` | Only on `bounced` sends: the receiving server's message, whitespace collapsed and cut to 200 characters. It quotes the recipient address, so it follows the stricter of email and messages visibility: `null` when either is hidden, `Restricted` when either is masked. | | `data.replied` | `boolean` | `true` when DialEcho matched a reply to this send. | | `data.first_reply_at` | `timestamp \| null` | When the first matched reply came. | | `data.opted_out` | `boolean` | `true` when the recipient opted out in a reply to this send or the provider reported an unsubscribe. | | `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when there is none. | | `has_more` | `boolean` | `true` while `next_cursor` is set. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/blasts/22222222-0000-4000-8000-000000000001/sends?status=bounced&limit=1" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/blasts/22222222-0000-4000-8000-000000000001/sends?status=bounced&limit=1", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "data": [ { "id": "11111111-0000-4000-8000-000000000002", "blast_id": "22222222-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "email": "gone@example.net", "lead_id": "aaaaaaaa-0000-4000-8000-000000000002", "status": "bounced", "sent_at": "2026-11-03T15:04:10Z", "from_email": "jordan@outreach.example.com", "subject": "Sam, your solar quote is ready", "bounce_code": "5.1.1", "bounce_diagnostic": "550 5.1.1 : Recipient address rejected: User unknown", "replied": false, "first_reply_at": null, "opted_out": false } ], "next_cursor": "v2.exampleOpaqueCursor", "has_more": true } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `blasts:read`. Checked before the query is validated. | | 404 | `not_found` | The blast does not exist or the key cannot read it: another organization, a campaign outside the key, or one where the key's member cannot use email blasts. | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor. | | 422 | `invalid_cursor` | The cursor was not issued to this key for this blast, was changed, or was sent with different filters or without its `email`. | | 422 | `invalid_request` | `blast_id` is not a UUID, a filter is invalid (`error.fields` names the first: `status`, `since`, `query.limit`, `query.cursor`), or `email` is not an email address (then `error.fields` is empty). | | 503 | `dependency_unavailable` | Blast data or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/blasts/{blast_id}` · [Retrieve a blast](/api/blasts/get) - `GET /v1/sends` · [Look up sends](/api/blasts/lookup-sends) --- # Look up sends `GET /v1/sends` Lists every blast email sent to one address, oldest first. **Scope:** `blasts:read` Answers "did we email this person, and with what": every send to the address with a `sent_at`, in any campaign whose blasts the key can read and where its member sees email addresses in full (elsewhere nothing matches, never `403`), ordered by `sent_at` (then `id`), oldest first, inside the window the first page fixes. Recipients not sent yet, suppressed or skipped were never emailed and are not listed. Rows have the same shape as a blast's sends. Send the same `email` with every `cursor`; the cursor does not store the address. ## Query parameters | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | `string` | Yes | The address, compared after trimming and lowercasing. | | `limit` | `integer` | No | Sends per page. 1 to 500. Default: `100`. | | `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. The cursor does not store the address, so send the same `email` with it. Up to 4,096 characters. | ## Response **Status:** `200 OK` | Name | Type | Description | | --- | --- | --- | | `data` | `array of objects` | Sends to the address, oldest first. | | `data.id` | `UUID` | The send: one recipient of one blast. | | `data.blast_id` | `UUID` | Its blast. | | `data.campaign_id` | `UUID` | The blast's campaign. | | `data.email` | `string \| null` | The recipient address; `null` when the member's role hides email, masked (`p***@example.net`) when it masks it. | | `data.lead_id` | `UUID \| null` | The lead it went to, when the recipient is a lead. | | `data.status` | `string` | The send status. `pending` and `processing` have not gone out yet; `suppressed`, `skipped` and `cancelled` never will. One of `pending`, `processing`, `sent`, `delivered`, `failed`, `bounced`, `suppressed`, `skipped`, `cancelled`. | | `data.sent_at` | `timestamp \| null` | When it went out (RFC 3339, UTC); `null` while not sent. | | `data.from_email` | `string \| null` | The mailbox it was sent from, with the same email visibility as `email`. | | `data.subject` | `string \| null` | The subject as the recipient got it; `Restricted` when the member's role masks messages. | | `data.bounce_code` | `string \| null` | Only on `bounced` sends: the bounce code, for example `5.1.1`. | | `data.bounce_diagnostic` | `string \| null` | Only on `bounced` sends: the receiving server's message, whitespace collapsed and cut to 200 characters. It quotes the recipient address, so it follows the stricter of email and messages visibility: `null` when either is hidden, `Restricted` when either is masked. | | `data.replied` | `boolean` | `true` when DialEcho matched a reply to this send. | | `data.first_reply_at` | `timestamp \| null` | When the first matched reply came. | | `data.opted_out` | `boolean` | `true` when the recipient opted out in a reply to this send or the provider reported an unsubscribe. | | `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when there is none. | | `has_more` | `boolean` | `true` while `next_cursor` is set. | ## Example request **curl** ```bash curl "https://development.agencydevworks.ai/v1/sends?email=pat@example.com" \ -H "Authorization: Bearer dei_your_key" ``` **JavaScript** ```js const response = await fetch("https://development.agencydevworks.ai/v1/sends?email=pat@example.com", { headers: { "Authorization": "Bearer dei_your_key", }, }); const data = await response.json(); ``` ## Example response **Status:** `200 OK` ```json { "data": [ { "id": "11111111-0000-4000-8000-000000000001", "blast_id": "22222222-0000-4000-8000-000000000001", "campaign_id": "cccccccc-0000-4000-8000-000000000001", "email": "pat@example.com", "lead_id": "aaaaaaaa-0000-4000-8000-000000000001", "status": "delivered", "sent_at": "2026-11-03T15:02:55Z", "from_email": "jordan@outreach.example.com", "subject": "Pat, your solar quote is ready", "bounce_code": null, "bounce_diagnostic": null, "replied": true, "first_reply_at": "2026-11-03T17:41:09Z", "opted_out": false } ], "next_cursor": null, "has_more": false } ``` ## Errors | HTTP | Code | When | | --- | --- | --- | | 403 | `insufficient_scope` | The key does not have `blasts:read`. Checked before the query is validated. | | 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor. | | 422 | `invalid_cursor` | The cursor was not issued to this key for this lookup, was changed, or was sent without its `email`. | | 422 | `invalid_request` | `email` is missing (`query.email`, `missing`) or is not an email address (then `error.fields` is empty), or `limit` or `cursor` is invalid. | | 503 | `dependency_unavailable` | Blast data or the access check is temporarily unavailable. Retry with backoff. | Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview. ## Related endpoints - `GET /v1/blasts/{blast_id}/sends` · [List a blast's sends](/api/blasts/list-sends) - `GET /v1/blasts/{blast_id}` · [Retrieve a blast](/api/blasts/get)