# 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 <key>`.

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`. |
