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
- POST
/· Create or update a leadv1/ leads - PATCH
/· Update a leadv1/ leads/ {lead_id} - GET
/· Retrieve a leadv1/ leads/ {lead_id} - PUT
/· Move a lead's cardv1/ leads/ {lead_id}/ stage
- GET
/· List open timesv1/ campaigns/ {campaign_id}/ appointment-slots - PUT
/· Book or reschedulev1/ leads/ {lead_id}/ appointment
- POST
/· Upload a filev1/ leads/ {lead_id}/ files - DELETE
/· Delete a filev1/ leads/ {lead_id}/ files/ {file_id}
- POST
/· Place an AI callv1/ calls
- GET
/· List conversionsv1/ campaigns/ {campaign_id}/ conversions - GET
/· Retrieve a conversionv1/ leads/ {lead_id}/ conversion - GET
/· Retrieve the pipelinev1/ campaigns/ {campaign_id}/ pipeline - GET
/· List a stage's leadsv1/ campaigns/ {campaign_id}/ pipeline/ stages/ {stage}/ leads
- POST
/· Verify a launch tokenv1/ lead-actions/ verify
- GET
/· List conversationsv1/ inbox/ conversations - GET
/· Retrieve a conversationv1/ inbox/ conversations/ {conversation_id} - GET
/· List inbound messagesv1/ inbox/ inbound-messages - POST
/· Reply to a conversationv1/ inbox/ conversations/ {conversation_id}/ replies - PATCH
/· Update a conversationv1/ inbox/ conversations/ {conversation_id}
- GET
/· List blastsv1/ blasts - GET
/· Retrieve a blastv1/ blasts/ {blast_id} - GET
/· List a blast's sendsv1/ blasts/ {blast_id}/ sends - GET
/· Look up sendsv1/ sends
Base URL
text
https://development.agencydevworks.aiAll 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.
Every request
http
Authorization: Bearer dei_your_keyScopes
| 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: 3Budgets
| 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-00000000abcdEndpoints
| Endpoint | Idempotency-Key | A retry with the same key and request |
|---|---|---|
POST / | 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 / | Required | Returns 201 with the same file_id and stores nothing new. |
POST / | 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 / | 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 /, 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. |