The EZPZ API
Read and write your agency's book with the same permissions your team has.
- Make a key in Studio › API & webhooks.
- A key acts as the roles you give it. It never reaches more than the person who made it.
- A key can be limited to chosen fields. Every read and webhook it makes then carries only those fields, plus
id. - Included in the Agency plan.
- Spec file (OpenAPI 3.1) for code generators and API tools.
Authentication
Send your key as a bearer token on every request.
Authorization: Bearer ezpz_live_…We show the secret once, when you make the key. We keep only a hash of it, so copy it then. Revoke a key in Studio at any time; it stops working at once.
Pagination
List endpoints take limit (default 50, max 100) and cursor. Pass the next_cursor you got back to read the next page. It is null on the last page.
{
"object": "list",
"data": [ … ],
"next_cursor": "…" | null
}Errors
A failed request answers with a status code and one stable code you can branch on.
{ "error": "<code>" }| Code | Status | What it means |
|---|---|---|
unauthenticated | 401 | No key, a malformed key, or a revoked one. |
forbidden | 403 | The key is real but its roles do not allow this, or the record is outside its reach. |
plan_required | 403 | The agency is not on a plan that includes the API. |
not_found | 404 | No such record, or one the key cannot see. |
validation | 422 · 400 | The body or query did not pass. 400 when an id is malformed. |
unknown_param | 400 | A custom endpoint got a query parameter it does not take. The endpoint is the query. |
rate_limited | 429 | Too many requests. Wait the seconds in the Retry-After header. |
db_error | 500 | Our fault. Retry with backoff. |
not_ready | 503 | This part of the API is still being switched on. Retry in a few minutes. |
Rate limits
Each key gets 600 requests a minute and 50,000 a day. Past either, you get 429 rate_limited with a Retry-After header in seconds.
Ids carry a prefix (ld_…, ag_…). Money is integer cents. Times are ISO 8601 in UTC; dates are YYYY-MM-DD. A key sees only the records its roles reach.
lead
A person an agent is working: contact facts, where they came from, and where they stand.
- GET /api/v1/leads?phone=&email=List leads, newest first.
phonefinds a lead by its number in any common US spelling;emailby its address as typed or in lower case. - GET /api/v1/leads/{id}Get one lead.
- POST /api/v1/leadsCreate a lead. Once your agency signs the key's consent promise in Studio, a lead the key posts with a
phonecan get texts.sms_consent: {text, at}is optional: the person's own consent words and when, kept as the proof. Before the promise is signed, a lead needs them to get texts. - PATCH /api/v1/leads/{id}Change its name, phone, email, state or product (
first_name,last_name,phone,email,state,product_type), its agency fields (custom) or its stage (stage_id). A note is its own call (below).addressandsourceare refused. - POST /api/v1/leads/{id}/notesAdd a note. It shows in the lead's
notes. - POST /api/v1/leads/{id}/tagsAdd or remove tags:
{add?, remove?}, lists of tag names. - POST /api/v1/leads/{id}/appointmentsBook an appointment.
custom holds your agency's own fields on this object, keyed by field key. A field that looks like health data is never sent.
| Field | Type | Notes |
|---|---|---|
id | string | The lead id. |
owner_id | agent id | The agent who owns the record (an agent id). |
first_name | string or null | First name. |
last_name | string or null | Last name. |
email | string or null | Email address. |
phone | string or null | Phone number, E.164 where known. |
state | string or null | Two-letter US state. |
zip | string or null | ZIP code. |
age | integer or null | Age in years, as given. |
disposition | string | The fixed disposition (new_lead, called, booked, sold, …). Canonical; stage is the agency's word for it. |
stage | object or null | The agency stage it shows: {id, key, label}, or null for the fixed label. |
source | string | Where the lead came from (facebook, transferred, mail_in, manual, …). A lead created through this API reads manual. |
product_type | string | The product line the lead asked about (mp, iul, fex, annu or life). A lead filed on a lead type holds the type's base product. |
lead_type | object or null | The lead type it is filed on: {key, label} — the agent's or the agency's own type, which works like its product_type. Null for a plain product. |
tags | array | Tags, as strings. |
notes | string or null | The agent's notes on the lead. |
score | integer or null | Lead score, 0–100. |
score_warmth_label | string or null | The warmth word beside the score. |
call_count | integer | The current call streak. |
dial_attempts | integer | Every dial ever placed to the lead. |
voicemails_left | integer | Voicemails left. |
callback_at | timestamp or null | A scheduled callback. |
closed_at | timestamp or null | When the lead was closed. |
last_touched_at | timestamp | The last time anyone acted on the lead. |
last_dialed_at | timestamp or null | The last dial. |
last_inbound_at | timestamp or null | The last inbound text or call. |
cost_cents | integer (cents) | What the lead cost (cents). |
campaign_id | string or null | The ad campaign it came from (an EZPZ campaign id). |
origin_channel | string or null | The channel it first arrived through. |
referred_by_lead_id | lead id or null | The lead who referred this one. |
custom | object | Agency field values, keyed by field key. Health-shaped fields are never included. |
created_at | timestamp | When the record was created. |
updated_at | timestamp | When the record last changed. |
curl https://www.buildezpz.com/api/v1/leads/ld_2b3c4d5e-0000-4000-8000-00000001ead1 \
-H "Authorization: Bearer ezpz_live_…"{
"object": "lead",
"id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1",
"owner_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"first_name": "Dana",
"last_name": "Ruiz",
"email": "dana.ruiz@example.com",
"phone": "+15555550142",
"state": "TX",
"zip": "78701",
"age": 54,
"disposition": "booked",
"stage": {
"id": "st_92a3b4c5-0000-4000-8000-0000000057a6",
"key": "appointment_set",
"label": "Appointment set"
},
"source": "facebook",
"product_type": "final_expense",
"lead_type": {
"key": "veterans",
"label": "Veterans"
},
"tags": [
"evenings"
],
"notes": "Prefers a call after 6 pm.",
"score": 82,
"score_warmth_label": "Warm",
"call_count": 2,
"dial_attempts": 5,
"voicemails_left": 1,
"callback_at": null,
"closed_at": null,
"last_touched_at": "2026-09-30T18:04:11Z",
"last_dialed_at": "2026-09-30T17:58:40Z",
"last_inbound_at": "2026-09-30T18:02:09Z",
"cost_cents": 2400,
"campaign_id": "c5d6e7f8-0000-4000-8000-0000000ca3e1",
"origin_channel": "facebook",
"referred_by_lead_id": null,
"custom": {
"coverage_goal": "burial"
},
"created_at": "2026-09-28T15:20:00Z",
"updated_at": "2026-09-30T18:04:11Z"
}Never exposed: sensitive — date_of_birth, raw_payload; vendor ids — meta_*, gclid, base44_id; internal — custody_*, copied_from_lead_id, listing_id, purchased_from_listing_id, pool_listed_at, resale_owner_id, purge_hold_at, scrub_*, last_inbound_preview.
policy
A sale (or a policy in motion) on a lead: carrier, product, premium and where it stands.
- GET /api/v1/policiesList policies.
- GET /api/v1/policies/{id}Get one policy.
- POST /api/v1/policiesRecord a sale on a lead your key's creator owns:
{lead_id, ap_cents, commission_pct, close_date, …}. The lead closes, as on the screen. - PATCH /api/v1/policies/{id}Change a sale your key's creator owns.
custom holds your agency's own fields on this object, keyed by field key. A field that looks like health data is never sent.
| Field | Type | Notes |
|---|---|---|
id | string | The policy id. |
lead_id | lead id | The lead it was sold to. |
owner_id | agent id | The agent who owns the record (an agent id). |
ap_cents | integer (cents) | Annual premium (cents). |
advanced_pay_cents | integer (cents) | Advance paid (cents). |
commission_cents | integer (cents) | Commission (cents). |
commission_pct | number or null | Commission percent (two decimals). |
face_amount_cents | integer (cents) or null | Face amount (cents). |
carrier_name | string or null | Carrier. |
carrier_product | string or null | Carrier product. |
policy_number | string or null | Policy number. |
policy_stage | string | Where the policy stands (submitted, issued, paid, lapsed, …). |
policy_stage_at | timestamp | When it reached that stage. |
close_date | date | The sale date. |
effective_date | date or null | The policy effective date. |
sold_reason | string or null | Why it sold, as noted. |
policy_note | string or null | The agent's note on the policy. |
client_first_name | string or null | The insured's first name, when not the lead. |
client_last_name | string or null | The insured's last name, when not the lead. |
call_id | call id or null | The call it closed on. |
custom | object | Agency policy field values, keyed by field key. Health-shaped fields are never included. |
created_at | timestamp | When the record was created. |
updated_at | timestamp | When the record last changed. |
curl https://www.buildezpz.com/api/v1/policies/pl_3c4d5e6f-0000-4000-8000-0000000b0011 \
-H "Authorization: Bearer ezpz_live_…"{
"object": "policy",
"id": "pl_3c4d5e6f-0000-4000-8000-0000000b0011",
"lead_id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1",
"owner_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"ap_cents": 102000,
"advanced_pay_cents": 76500,
"commission_cents": 102000,
"commission_pct": 100,
"face_amount_cents": 1500000,
"carrier_name": "Example Mutual",
"carrier_product": "Level Benefit Whole Life",
"policy_number": "EX-0001245",
"policy_stage": "issued",
"policy_stage_at": "2026-10-01T14:00:00Z",
"close_date": "2026-09-30",
"effective_date": "2026-10-15",
"sold_reason": "Wanted burial covered for her kids.",
"policy_note": null,
"client_first_name": null,
"client_last_name": null,
"call_id": "cl_5e6f7081-0000-4000-8000-0000000ca111",
"custom": {},
"created_at": "2026-09-30T19:10:00Z",
"updated_at": "2026-10-01T14:00:00Z"
}Never exposed: sensitive — client_dob; internal — closed_by, stage_nudge_*, contact_idx.
appointment
A booked meeting with a lead and what came of it.
- GET /api/v1/appointmentsList appointments.
- GET /api/v1/appointments/{id}Get one appointment.
| Field | Type | Notes |
|---|---|---|
id | string | The appointment id. |
lead_id | lead id | The lead it is with. |
owner_id | agent id | The agent who owns the record (an agent id). |
starts_at | timestamp | Start. |
ends_at | timestamp | End. |
timezone | string or null | The IANA time zone it was booked in. |
status | scheduled | cancelled | completed | no_show | Its state. |
outcome | string or null | What happened at the meeting. |
title | string or null | Title. |
notes | string or null | Notes. |
source | string or null | How it was booked. |
created_by | agent id or null | Who booked it (an agent id). |
created_at | timestamp | When the record was created. |
updated_at | timestamp | When the record last changed. |
curl https://www.buildezpz.com/api/v1/appointments/ap_4d5e6f70-0000-4000-8000-0000000a1101 \
-H "Authorization: Bearer ezpz_live_…"{
"object": "appointment",
"id": "ap_4d5e6f70-0000-4000-8000-0000000a1101",
"lead_id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1",
"owner_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"starts_at": "2026-10-01T23:00:00Z",
"ends_at": "2026-10-01T23:30:00Z",
"timezone": "America/Chicago",
"status": "scheduled",
"outcome": null,
"title": "Dana Ruiz — coverage review",
"notes": null,
"source": "echo",
"created_by": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"created_at": "2026-09-30T18:03:00Z",
"updated_at": "2026-09-30T18:03:00Z"
}Never exposed: vendor ids — google_*, html_link, meet_link.
call
A phone call, inbound or outbound, and how it went.
- GET /api/v1/callsList calls.
- GET /api/v1/calls/{id}Get one call.
| Field | Type | Notes |
|---|---|---|
id | string | The call id. |
lead_id | lead id or null | The lead on the call, when known. |
owner_id | agent id | The agent who owns the record (an agent id). |
direction | string | inbound or outbound. |
status | string | The call state (ringing, in_progress, completed, no_answer, …). |
started_at | timestamp or null | When it started. |
answered_at | timestamp or null | When it was answered. |
ended_at | timestamp or null | When it ended. |
duration_sec | integer or null | Talk time in seconds. |
voicemail | boolean | A voicemail was left or taken. |
from_number | string | From. |
to_number | string | To. |
caller_name | string or null | Caller name, when known. |
placed_by_actor_id | agent id or null | Who placed it, when not the owner (an agent id). |
answered_by | string or null | Who picked up (human or machine), when detected. |
recording_duration_sec | integer or null | Recording length in seconds. |
has_recording | boolean | A recording exists and has not been purged. |
transcription | string or null | The call transcript, when one was made. |
recording_link | string or null | A stable link to the audio while it exists: GET it with a key holding calls.listen for the call's owner and it answers 302 to a fresh short-lived file link (404 once the audio is gone). Null without calls.listen, or when there is no recording. |
created_at | timestamp | When the record was created. |
curl https://www.buildezpz.com/api/v1/calls/cl_5e6f7081-0000-4000-8000-0000000ca111 \
-H "Authorization: Bearer ezpz_live_…"{
"object": "call",
"id": "cl_5e6f7081-0000-4000-8000-0000000ca111",
"lead_id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1",
"owner_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"direction": "outbound",
"status": "completed",
"started_at": "2026-09-30T17:58:40Z",
"answered_at": "2026-09-30T17:58:52Z",
"ended_at": "2026-09-30T18:05:44Z",
"duration_sec": 412,
"voicemail": false,
"from_number": "+15555550100",
"to_number": "+15555550142",
"caller_name": null,
"placed_by_actor_id": null,
"answered_by": "human",
"recording_duration_sec": 405,
"has_recording": true,
"transcription": null,
"recording_link": null,
"created_at": "2026-09-30T17:58:40Z"
}Never exposed: vendor ids — provider*, twilio_*, *_sid, agent_leg_call_control_id, stir_*, caller_line_type, hangup_cause, recording_url; internal — quality_flags, error_*, *_alerted_at, *_started_at.
message
A text message to or from a lead.
- GET /api/v1/messagesList text messages.
| Field | Type | Notes |
|---|---|---|
id | string | The message id. |
lead_id | lead id or null | The lead, when known. |
owner_id | agent id | The agent who owns the record (an agent id). |
direction | string | inbound or outbound. |
body | string | The text. |
status | string | Delivery state. |
from_number | string | From. |
to_number | string | To. |
num_media | integer or null | Attached media count. |
send_origin | string or null | What sent it (manual, automation, …). |
sent_by_kind | string or null | owner, delegate or echo. |
sent_by_actor_id | agent id or null | Who sent it, when not the owner (an agent id). |
read_at | timestamp or null | When the agent read it. |
created_at | timestamp | When the record was created. |
curl "https://www.buildezpz.com/api/v1/messages?limit=1" \
-H "Authorization: Bearer ezpz_live_…"{
"object": "list",
"data": [
{
"object": "message",
"id": "ms_6f708192-0000-4000-8000-0000000e5501",
"lead_id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1",
"owner_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"direction": "inbound",
"body": "Tuesday at 6 works for me.",
"status": "received",
"from_number": "+15555550142",
"to_number": "+15555550100",
"num_media": 0,
"send_origin": null,
"sent_by_kind": null,
"sent_by_actor_id": null,
"read_at": "2026-09-30T18:02:30Z",
"created_at": "2026-09-30T18:02:09Z"
}
],
"next_cursor": null
}Never exposed: vendor ids — provider*, twilio_message_sid, subaccount_sid; internal — error_*, opt_out_*, send_class, send_subkind, workflow_id; billing — num_segments.
agent
A person on the agency's team.
- GET /api/v1/agentsList the agents your key can see.
- GET /api/v1/agents/{id}Get one agent.
custom holds your agency's own fields on this object, keyed by field key. A field that looks like health data is never sent.
| Field | Type | Notes |
|---|---|---|
id | string | The agent id. |
full_name | string or null | Full name. |
display_name | string or null | The name they go by. |
business_name | string or null | Their business name. |
email | string | Email. |
phone | string or null | Phone. |
agency_id | agency id or null | Their agency. |
role | agency_owner | agency_admin | agency_assistant | member or null | Their seat in the agency; null when they hold none. |
manager_id | agent id or null | Their upline manager. |
npn | string or null | National Producer Number. |
timezone | string or null | IANA time zone. |
work_state | string or null | Home work state. |
avatar_url | string or null | Avatar image URL. |
status | string | Account status. |
custom | object | Agency agent field values, keyed by field key. Health-shaped fields are never included. |
created_at | timestamp | When the record was created. |
curl https://www.buildezpz.com/api/v1/agents/ag_1a2b3c4d-0000-4000-8000-0000000a6e01 \
-H "Authorization: Bearer ezpz_live_…"{
"object": "agent",
"id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"full_name": "Sam Okafor",
"display_name": "Sam",
"business_name": "Okafor Family Coverage",
"email": "sam.okafor@example.com",
"phone": "+15555550100",
"agency_id": "ay_0c1d2e3f-0000-4000-8000-00000000a9e1",
"role": "member",
"manager_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e02",
"npn": "99990001",
"timezone": "America/Chicago",
"work_state": "TX",
"avatar_url": null,
"status": "active",
"custom": {
"contract_level": 90
},
"created_at": "2026-01-12T16:00:00Z"
}Never exposed: sign-in secrets — client_data_pin_hash, phone_e164_*; vendor ids — base44_id, telephony_provider; internal — referral_code*, pz_*, va_of_user_id, paused_*, scrub_*, *_override*, sms_*, email_*, setup_progress, discovery_questions, dismissed_announcements; billing — admin_*, access_granted_by_admin, comp_source, lead_ceiling.
role
A custom role the agency defined in Studio, and the switches it grants.
- GET /api/v1/rolesList your agency's custom roles.
| Field | Type | Notes |
|---|---|---|
id | string | The role id. |
agency_id | agency id | The defining agency. |
name | string | Name. |
description | string | Description. |
switches | object | Switch key → the reaches it grants (a data switch) or true (an agency-wide switch). |
push_mode | offered | suggested | extendable | locked or null | Pushed to the agencies below: offered, suggested, extendable or locked; null = not pushed. |
status | draft | live | retired | draft, live or retired. |
created_at | timestamp | When the record was created. |
curl "https://www.buildezpz.com/api/v1/roles?limit=1" \
-H "Authorization: Bearer ezpz_live_…"{
"object": "list",
"data": [
{
"object": "role",
"id": "rl_708192a3-0000-4000-8000-0000000501e1",
"agency_id": "ay_0c1d2e3f-0000-4000-8000-00000000a9e1",
"name": "Closer",
"description": "Works their own leads and sees the team board.",
"switches": {
"leads.view": [
"own"
],
"leads.edit": [
"own"
],
"calls.dial": [
"own"
],
"team.production": [
"team"
],
"build.reports": true
},
"push_mode": null,
"status": "live",
"created_at": "2026-09-01T15:00:00Z"
}
],
"next_cursor": null
}Never exposed: internal — grants, created_by.
field
A custom field the agency defined on leads, policies or agents.
- GET /api/v1/fieldsList your agency's custom fields.
| Field | Type | Notes |
|---|---|---|
id | string | The field id. |
agency_id | agency id | The defining agency. |
object | lead | policy | agent | What it is on. |
key | string | The key its values appear under in custom. |
label | string | Label. |
type | string | Field type (text, number, date, select, multi_select, checkbox, …). |
options | array | Choices {value, label} for a pick field. |
required | boolean | Required. |
help | string | Help text. |
position | integer | Display order. |
push_mode | offered | suggested | extendable | locked or null | Pushed to the agencies below; null = not pushed. |
status | draft | live | retired | draft, live or retired. |
created_at | timestamp | When the record was created. |
curl "https://www.buildezpz.com/api/v1/fields?limit=1" \
-H "Authorization: Bearer ezpz_live_…"{
"object": "list",
"data": [
{
"object": "lead",
"id": "fd_8192a3b4-0000-4000-8000-0000000f1e1d",
"agency_id": "ay_0c1d2e3f-0000-4000-8000-00000000a9e1",
"key": "coverage_goal",
"label": "Coverage goal",
"type": "select",
"options": [
{
"value": "burial",
"label": "Burial"
},
{
"value": "income",
"label": "Income"
}
],
"required": false,
"help": "What the client wants the policy to do.",
"position": 1,
"push_mode": "extendable",
"status": "live",
"created_at": "2026-09-01T15:00:00Z"
}
],
"next_cursor": null
}Never exposed: internal — created_by.
stage
An agency stage: the agency's own word for one of the fixed dispositions.
- GET /api/v1/stagesList your agency's stages.
| Field | Type | Notes |
|---|---|---|
id | string | The stage id. |
agency_id | agency id | The defining agency. |
key | string | Key. |
label | string | Label. |
maps_to | string | The fixed disposition it counts as. |
tone | string or null | Dot colour. |
position | integer | Display order. |
push_mode | offered | suggested | extendable | locked or null | Pushed to the agencies below; null = not pushed. |
status | draft | live | retired | draft, live or retired. |
created_at | timestamp | When the record was created. |
curl "https://www.buildezpz.com/api/v1/stages?limit=1" \
-H "Authorization: Bearer ezpz_live_…"{
"object": "list",
"data": [
{
"object": "stage",
"id": "st_92a3b4c5-0000-4000-8000-0000000057a6",
"agency_id": "ay_0c1d2e3f-0000-4000-8000-00000000a9e1",
"key": "appointment_set",
"label": "Appointment set",
"maps_to": "booked",
"tone": "sea",
"position": 3,
"push_mode": null,
"status": "live",
"created_at": "2026-09-01T15:00:00Z"
}
],
"next_cursor": null
}Never exposed: internal — created_by.
table
A custom table that applies in your agency: its own, or one pushed to it. Only live tables are listed.
- GET /api/v1/tablesList the custom tables live in your agency, each with its fields.
- GET /api/v1/tables/{id}Get one table.
| Field | Type | Notes |
|---|---|---|
id | string | The table id. |
agency_id | agency id | The defining agency. |
key | string | Key. |
name | string | Name. |
record_noun | string | What one record is called. |
icon | string or null | Icon name. |
shared | boolean | Shared across the agency. |
on_lead | boolean | Shown on the lead card. |
status | draft | live | retired | draft, live or retired. |
fields | array | Its fields [{key, label, type, options, required}] — the keys a record's data uses. A link field is read-only here (its values are links). Health-shaped fields are never included. |
curl https://www.buildezpz.com/api/v1/tables/tb_a3b4c5d6-0000-4000-8000-00000007ab1e \
-H "Authorization: Bearer ezpz_live_…"{
"object": "table",
"id": "tb_a3b4c5d6-0000-4000-8000-00000007ab1e",
"agency_id": "ay_0c1d2e3f-0000-4000-8000-00000000a9e1",
"key": "referrals",
"name": "Referrals",
"record_noun": "referral",
"icon": null,
"shared": true,
"on_lead": true,
"status": "live",
"fields": [
{
"key": "relationship",
"label": "Relationship",
"type": "text",
"options": [],
"required": true
}
]
}Never exposed: internal — created_by.
record
A record in a custom table. owner_id is null on an agency-shared table.
- GET /api/v1/records?table_id=&owner_id=&updated_since=List one table's records, newest first.
table_idis required. A list past your agency's rows-per-filter limit (20,000 unless raised) answers 422too_many_rows: narrow it byowner_idorupdated_since. - GET /api/v1/records/{id}Get one record.
- POST /api/v1/recordsCreate a record:
{table_id, owner_id?, data},datakeyed by field key. Withoutowner_idit belongs to your key's default owner (a shared table's records have none). Link fields are read-only. - PATCH /api/v1/records/{id}Change some of its values:
{data}, keyed by field key;nullclears one. - DELETE /api/v1/records/{id}Delete it.
data holds the table's fields, keyed by field key (the table's fields list them). A field that looks like health data is never sent.
| Field | Type | Notes |
|---|---|---|
id | string | The record id. |
table_id | table id | Its table. |
agency_id | agency id | The agency. |
owner_id | agent id or null | The agent who owns the record (an agent id). |
title | string | Title: the value of the table's first text field. |
data | object | Values keyed by the table's field KEY (not the definition id). Health-shaped fields are never included. |
links | array | What it links to [{kind, id}] (kind: record, lead or policy) — only targets your key can read. Read-only in v1. |
created_at | timestamp | When the record was created. |
updated_at | timestamp | When the record last changed. |
curl https://www.buildezpz.com/api/v1/records/rc_b4c5d6e7-0000-4000-8000-0000000ec0d1 \
-H "Authorization: Bearer ezpz_live_…"{
"object": "record",
"id": "rc_b4c5d6e7-0000-4000-8000-0000000ec0d1",
"table_id": "tb_a3b4c5d6-0000-4000-8000-00000007ab1e",
"agency_id": "ay_0c1d2e3f-0000-4000-8000-00000000a9e1",
"owner_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"title": "Referral from Dana Ruiz",
"data": {
"relationship": "Neighbor"
},
"links": [
{
"kind": "lead",
"id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1"
}
],
"created_at": "2026-09-30T19:20:00Z",
"updated_at": "2026-09-30T19:20:00Z"
}Never exposed: nothing beyond the rule — a column that is not a field above is never sent.
agency
The agency itself and where it sits in the tree.
- GET /api/v1/agencyGet your agency.
| Field | Type | Notes |
|---|---|---|
id | string | The agency id. |
name | string | Name. |
parent_id | agency id or null | The agency above it, if any. |
tier | string | Agency kind (portal, enterprise, external, spinoff). |
created_at | timestamp | When the record was created. |
curl https://www.buildezpz.com/api/v1/agency \
-H "Authorization: Bearer ezpz_live_…"{
"object": "agency",
"id": "ay_0c1d2e3f-0000-4000-8000-00000000a9e1",
"name": "Example Agency Group",
"parent_id": null,
"tier": "portal",
"created_at": "2025-11-03T15:00:00Z"
}Never exposed: vendor ids — telnyx_*; internal — owner_email; billing — monthly_price, portal_comped_*, stripe_*; platform costs — *pricing*.
kpi_day
One agent's numbers for one day, computed from the ledgers. Id kd_<agent uuid>_<YYYY-MM-DD>. Totals: not subject to the consent rule, like Team production.
- GET /api/v1/kpi_days?from=&to=&agent_id=One row per agent per day between
fromandto(dates, at most 93 days).agent_idis optional.
| Field | Type | Notes |
|---|---|---|
id | string | The day id: kd_<agent uuid>_<date>. |
agent_id | agent id | The agent. |
date | date | The day, in the agent's time zone. |
dials | integer | Dials. |
contacts | integer | Contacts. |
appointments_set | integer | Appointments booked. |
appointments_held | integer | Appointments held. |
presentations | integer | Presentations. |
closes | integer | Closes. |
texts_sent | integer | Texts sent. |
talk_seconds | integer | Talk time (seconds). |
leads_received | integer | New leads received. |
ap_cents | integer (cents) | Annual premium closed (cents). |
spend_cents | integer (cents) | Ad and lead spend (cents). |
lead_cost_cents | integer (cents) | The summed cost of the leads received (cents). |
cost_per_lead_cents | integer (cents) or null | DERIVED: spend_cents ÷ leads_received, rounded; null when no leads. |
cost_per_close_cents | integer (cents) or null | DERIVED: spend_cents ÷ closes, rounded; null when no closes. |
curl "https://www.buildezpz.com/api/v1/kpi_days?from=2026-09-30&to=2026-09-30&agent_id=ag_1a2b3c4d-0000-4000-8000-0000000a6e01" \
-H "Authorization: Bearer ezpz_live_…"{
"object": "list",
"data": [
{
"object": "kpi_day",
"id": "kd_1a2b3c4d-0000-4000-8000-0000000a6e01_2026-09-30",
"agent_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"date": "2026-09-30",
"dials": 118,
"contacts": 31,
"appointments_set": 6,
"appointments_held": 4,
"presentations": 4,
"closes": 2,
"texts_sent": 85,
"talk_seconds": 9420,
"leads_received": 14,
"ap_cents": 204000,
"spend_cents": 42000,
"lead_cost_cents": 33600,
"cost_per_lead_cents": 3000,
"cost_per_close_cents": 21000
}
],
"next_cursor": null
}Never exposed: nothing beyond the rule — a column that is not a field above is never sent.
sale
A sale posted to the agency's book of business, with the agent who posted it. Read-only, oldest change first: poll GET /api/v1/sales with the next_cursor you got last time to get every new, changed or deleted sale once, and upsert each by id. Changes appear after about 10 seconds. A deleted sale comes back with deleted: true. An API key sees the sales posted after it was created.
- GET /api/v1/salesEvery sale on your agency's book, oldest change first. Unlike other lists,
next_cursoris never null after your first sale: store it and pass it back ascursorto get only what is new, changed or deleted since. Fewer rows thanlimitmeans you are caught up. Changes appear after about 10 seconds. A key sees sales posted after it was created. Needs Team production at Agency reach.
| Field | Type | Notes |
|---|---|---|
id | string | The sale id. |
agent_email | string | The agent's sign-in email, lower-cased. |
agent_contact_email | string or null | The agent's contact email, when they set one apart from the sign-in email. |
agent_first_name | string or null | The first word of the agent's full name. EZPZ stores one full name; this is a split of it. Null when the name is blank. |
agent_last_name | string or null | The rest of the agent's full name after the first word. Null when the name is one word or blank. |
annual_premium_cents | integer (cents) | Annual premium (cents): the monthly premium × 12. |
carrier | string or null | The carrier. |
product | string or null | The product within the carrier. |
sale_date | date | The sale date the agent entered; when none, the day it was posted (Mountain time). |
status | string | Where it stands, as entered: Submitted, Submitted - Pending Requirements, Packet Received, Issue Paid, Canceled, Declined, Lapsed or Chargeback. Empty when none is set yet. |
counted | boolean | It counts toward premium totals: a status is set and it is not Canceled, Declined, Lapsed or Chargeback. Always false when deleted. |
created_at | timestamp | When the record was created. |
updated_at | timestamp | When the sale last changed; on a deleted sale, when it was deleted. |
deleted | boolean | The sale was deleted. Its other fields are the last values it had. |
curl https://www.buildezpz.com/api/v1/sales \
-H "Authorization: Bearer ezpz_live_…"{
"object": "list",
"data": [
{
"object": "sale",
"id": "sl_d6e7f809-0000-4000-8000-00000005a1e1",
"agent_email": "sam.okafor@example.com",
"agent_contact_email": null,
"agent_first_name": "Sam",
"agent_last_name": "Okafor",
"annual_premium_cents": 102000,
"carrier": "Mutual of Omaha",
"product": "Living Promise",
"sale_date": "2026-09-30",
"status": "Submitted",
"counted": true,
"created_at": "2026-09-30T18:04:11.204518+00:00",
"updated_at": "2026-09-30T18:04:11.204518+00:00",
"deleted": false
}
],
"next_cursor": "WyIyMDI2LTA5LTMwVDE4OjA0OjExLjIwNDUxOCswMDowMCIsImQ2ZTdmODA5LTAwMDAtNDAwMC04MDAwLTAwMDAwMDA1YTFlMSJd"
}Never exposed: sensitive — client_name, state; internal — lead_type, external_id, month_label, month_order, row_order, discord_posted_at, effective_date*, fi_date, landed_at, submitted_date, canceled_at; billing — commission_percent.
onboarding_call
An onboarding call someone booked on one of the agency's onboarding pages (Desk ▸ Booking). Sent as the onboarding_call.booked webhook; there is no /api/v1 route for it yet. A key gets it when it holds team.production at a reach covering the whole agency (the sales feed's rule).
Webhook only: subscribe an endpoint to `onboarding_call.booked`. No /api/v1 route yet.
| Field | Type | Notes |
|---|---|---|
id | string | The onboarding call id. |
full_name | string | The name the person typed when they booked. |
email | string | The email they typed, lower-cased. |
phone | string | The phone they typed, as typed. |
member_email | string or null | Set when the email is an agency member's: the member it matched. Null for someone who has not joined. |
host_email | string | The sign-in email of the staff member whose page it was booked on, lower-cased. They host the call. |
starts_at | timestamp | When the call starts. |
ends_at | timestamp | When the call ends. |
timezone | string | The page's time zone (an IANA name, e.g. America/Chicago). |
meet_link | string or null | The Google Meet link, when the calendar made one. |
status | booked | done | no_show | cancelled | Where it stands: booked, then done, no_show or cancelled. |
created_at | timestamp | When the record was created. |
updated_at | timestamp | When the record last changed. |
{
"object": "onboarding_call",
"id": "oc_e7f8091a-0000-4000-8000-0000000ca11b",
"full_name": "Jordan Lee",
"email": "jordan.lee@example.com",
"phone": "(555) 010-4477",
"member_email": "jordan.lee@example.com",
"host_email": "sam.okafor@example.com",
"starts_at": "2026-10-01T16:00:00+00:00",
"ends_at": "2026-10-01T16:30:00+00:00",
"timezone": "America/Chicago",
"meet_link": "https://meet.google.com/abc-defg-hij",
"status": "booked",
"created_at": "2026-09-30T18:04:11.204518+00:00",
"updated_at": "2026-09-30T18:04:11.204518+00:00"
}Never exposed: vendor ids — google_event_id; internal — agency_id, host_user_id, recruit_id, status_changed_at, status_changed_by.
Custom endpoints
An agency can save its own read in Studio › API & webhooks: one kind of data, up to five filters, the fields to send and a name. It answers at its own URL.
GET /api/v1/views/<slug>
Authorization: Bearer ezpz_live_…- It runs as your key. You get only the records your key's roles reach, and only the fields your key may see.
- The endpoint is the query. Send only
cursor,limitand the time floor of its data:updated_sincefor leads and records,sincefor every other kind but sales. Anything else answers400 unknown_param. - Some filters run after a page is read, so a page can hold fewer items than
limit, even none. Keep paging untilnext_cursorisnull. - An endpoint on sales pages like
GET /api/v1/sales: storenext_cursorand poll with it. - A slug your agency has not saved, or a key the endpoint does not let in, answers
404 not_found.
Inbound URLs
An agency can make a private URL in Studio › API & webhooks for an outside system — a lead vendor, a form tool, Zapier — to post leads to. It needs no key: the URL is the secret. Keep it private.
POST https://www.buildezpz.com/api/in/<token>
Content-Type: application/json
{ "first_name": "Ann", "phone": "512-555-0100", "email": "ann@example.com" }- Send one lead per request: one JSON object, up to 64 KB. The agency maps your fields onto its leads in Studio, so send your own shape.
- Each post needs a phone or an email. A lead the agency already has (same phone, then same email) is matched, not made twice.
- Until the agency signs its consent statement for the URL, every lead is held: it lands, and nothing texts it. Once it is signed, every lead with a phone can get texts. You may also send the consumer's own consent words and the time they gave it; they are kept as proof.
- Answers:
201created ·200matched ·202held for consent, each withlead_idandresult.400not JSON or unreadable ·413over 64 KB ·422 needs_phone_or_email·429too many (wait the seconds inRetry-After) ·404an unknown, paused or replaced URL. - Limits: 120 posts a minute and 10,000 a day per URL.
Events
Add an endpoint in Studio › API & webhooks and pick the events it gets. Each delivery is a POST with this JSON body.
| Event | When |
|---|---|
lead.created | A lead was added. |
lead.updated | A lead's fields or agency field values changed. |
lead.stage_changed | The disposition or the agency stage moved. |
lead.moved | The lead moved to another agent. |
lead.deleted | A lead was deleted. |
policy.created | A policy was recorded. |
policy.updated | A policy changed. |
policy.sold | A policy was sold (inserted, or its stage or close date set). |
policy.deleted | A policy was deleted. |
appointment.booked | An appointment was booked. |
appointment.rescheduled | Its time moved. |
appointment.outcome | Its outcome or status was set. |
appointment.deleted | An appointment was deleted. |
call.started | A call began. |
call.ended | A call ended. |
message.sent | An outbound text was sent (direction outbound). |
message.received | An inbound text arrived (direction inbound). |
record.created | A custom-table record was added (Phase 3). |
record.updated | A record or its links changed (Phase 3). |
record.deleted | A record was deleted (Phase 3). |
onboarding_call.booked | Someone booked an onboarding call on one of the agency's onboarding pages. |
kpi_day.updated | An agent's day changed; keyed by (agent, date). |
data.object is the full resource, exactly as the API returns it. changed lists the fields that changed.
{
"id": "evt_01J9EXAMPLE0000000000000001",
"type": "lead.stage_changed",
"created_at": "2026-09-30T18:04:12Z",
"api_version": "v1",
"data": {
"object": {
"object": "lead",
"id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1",
"owner_id": "ag_1a2b3c4d-0000-4000-8000-0000000a6e01",
"first_name": "Dana",
"last_name": "Ruiz",
"email": "dana.ruiz@example.com",
"phone": "+15555550142",
"state": "TX",
"zip": "78701",
"age": 54,
"disposition": "booked",
"stage": {
"id": "st_92a3b4c5-0000-4000-8000-0000000057a6",
"key": "appointment_set",
"label": "Appointment set"
},
"source": "facebook",
"product_type": "final_expense",
"lead_type": {
"key": "veterans",
"label": "Veterans"
},
"tags": [
"evenings"
],
"notes": "Prefers a call after 6 pm.",
"score": 82,
"score_warmth_label": "Warm",
"call_count": 2,
"dial_attempts": 5,
"voicemails_left": 1,
"callback_at": null,
"closed_at": null,
"last_touched_at": "2026-09-30T18:04:11Z",
"last_dialed_at": "2026-09-30T17:58:40Z",
"last_inbound_at": "2026-09-30T18:02:09Z",
"cost_cents": 2400,
"campaign_id": "c5d6e7f8-0000-4000-8000-0000000ca3e1",
"origin_channel": "facebook",
"referred_by_lead_id": null,
"custom": {
"coverage_goal": "burial"
},
"created_at": "2026-09-28T15:20:00Z",
"updated_at": "2026-09-30T18:04:11Z"
},
"changed": [
"stage",
"disposition"
]
}
}A delete carries only the id.
{
"id": "evt_01J9EXAMPLE0000000000000002",
"type": "lead.deleted",
"created_at": "2026-09-30T18:10:00Z",
"api_version": "v1",
"data": {
"object": {
"object": "lead",
"id": "ld_2b3c4d5e-0000-4000-8000-00000001ead1",
"deleted": true
}
}
}- Delivery is at least once, and events of different resources arrive in no set order. Dedupe on
ezpz-id, and read the record when order matters. policy.soldcan arrive more than once for the same policy — each time its stage or close date is set while it is live. Key on the policy id.- When a lead moves to an agent outside your reach, your endpoint gets
lead.movedwith only{ object, id, moved: true }— the agency it left hears that it went, not where. - An endpoint hears only changes made after it was created, and nothing while it is paused.
- Send test in Studio sends a
pingevent, signed like any other.
Verify a signature
Every delivery carries three headers:
ezpz-id— the event id.ezpz-timestamp— when we signed it, in unix seconds.ezpz-signature—v1,<base64>.
The signed content is ${id}.${timestamp}.${rawBody}, signed with HMAC-SHA256. The key is your endpoint secret (whsec_…): base64 after the prefix. Reject a timestamp more than 5 minutes off, and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (a string, before JSON.parse).
export function verifyEzpz(rawBody, headers, secret) {
const id = headers['ezpz-id'];
const timestamp = headers['ezpz-timestamp'];
const signature = headers['ezpz-signature'] ?? '';
if (!id || !timestamp || !signature.startsWith('v1,')) return false;
// Reject anything more than 5 minutes off.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
const given = Buffer.from(signature.slice('v1,'.length), 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
}Retries
- Answer with a 2xx within 5 seconds. Anything else is a failure.
- We try 8 times over about 16 hours: 1 m → 5 m → 15 m → 1 h → 2 h → 4 h → 4 h → 4 h.
- We do not follow redirects. Give us the final URL.
- An endpoint pauses after 50 failures in a row. Turn it back on in Studio.
- Endpoints must be HTTPS to a public host.
- Delivery is at least once. You may get the same event twice — dedupe on
ezpz-id.
Connect an AI agent
EZPZ runs an MCP server, so an AI agent such as Claude can read your book and propose changes. It sees the same records the API does, through the same permissions.
https://www.buildezpz.com/api/mcp- As yourself. Add the server URL as a connector in your AI app and sign in to EZPZ. On the consent screen you pick what it acts as: you, with all your powers or only some of your roles; or, if you manage API keys, one of your agency's keys.
- Headless. An agent with no sign-in screen sends an API key as the bearer:
Authorization: Bearer ezpz_live_…. It acts as that key. - Scopes.
ezpz.readreads.ezpz.writeadds the tools that propose changes. A key also needs theread_writescope to write. - Propose, then confirm. A
propose_…tool changes nothing. It checks the change, stores it for 15 minutes and answers an id and a summary. The agent shows you the summary and runsconfirm_actiononly when you say yes. Each confirm runs once and is logged in Studio › Activity. - Autopilot. Off by default. Turn it on for a key in Studio, or for your own connection in Settings › Apps, and proposals run at once. An agent can be steered by text it reads in a lead's messages, so turn it on only for agents you trust.
- Limits. Per connection: 60 requests a minute, 3,000 a day, and 30 writes a minute. Past one, you get
429with aRetry-Afterheader.
Tools
Each read answers exactly what its API endpoint answers. Write tools are listed only for a connection that may write.
| Tool | Kind | What it does | Same as |
|---|---|---|---|
ezpz_list_leads | Read | List leads, newest first. | GET /api/v1/leads |
ezpz_get_lead | Read | One lead. | GET /api/v1/leads/{id} |
ezpz_list_policies | Read | List policies, newest first. | GET /api/v1/policies |
ezpz_get_policy | Read | One policy. | GET /api/v1/policies/{id} |
ezpz_list_appointments | Read | List appointments, newest first. | GET /api/v1/appointments |
ezpz_list_calls | Read | List calls, newest first (a transcript only where the connection may listen). | GET /api/v1/calls |
ezpz_get_call | Read | One call. | GET /api/v1/calls/{id} |
ezpz_list_messages | Read | List text messages, newest first. | GET /api/v1/messages |
ezpz_list_agents | Read | List the agents this connection reaches. | GET /api/v1/agents |
ezpz_list_fields | Read | The agency’s own fields on leads, policies and agents. | GET /api/v1/fields |
ezpz_list_stages | Read | The agency’s lead stages. | GET /api/v1/stages |
ezpz_list_tables | Read | The agency’s custom tables, each with its fields. | GET /api/v1/tables |
ezpz_list_records | Read | List one custom table’s records, newest first. | GET /api/v1/records |
ezpz_get_record | Read | One custom-table record. | GET /api/v1/records/{id} |
propose_create_lead | Proposes a change | Propose adding a lead to the connection’s own book. | POST /api/v1/leads |
propose_update_lead | Proposes a change | Propose changing a lead’s agency fields or stage. | PATCH /api/v1/leads/{id} |
propose_add_note | Proposes a change | Propose adding a dated note to a lead. | POST /api/v1/leads/{id}/notes |
propose_create_record | Proposes a change | Propose adding a record to a custom table. | POST /api/v1/records |
propose_update_record | Proposes a change | Propose changing a custom-table record. | PATCH /api/v1/records/{id} |
propose_book_appointment | Proposes a change | Propose booking an appointment with a lead on its agent’s calendar. | POST /api/v1/leads/{id}/appointments |
confirm_action | Pending actions | Run a pending action the user approved. | — |
cancel_action | Pending actions | Drop a pending action the user declined. | — |
list_pending_actions | Pending actions | This connection’s pending actions that have not expired. | — |
Set up your agency with your AI
An agency's owner or admin can let their own AI set up the agency. On the consent screen, tick Set up my agency. That adds the ezpz.setup scope and the tools below, for that agency only.
- Small changes run at once. Each one shows in Studio › History, where the owner can Undo it.
- Big changes ask first. A tool marked “Asks first” stores a pending action. The AI asks you in its chat and runs
confirm_actiononly when you say yes. - It never sees your database password. Connecting a database stays in Studio › Data copy.
- Disconnect any time in Studio › Apps & AI.
| Tool | Step | Kind | What it does |
|---|---|---|---|
setup_status | The path | Read | Where the agency is on its setup: first Get running (the cards before Go live), then Go live (go_live_done), then Make it yours — the queue, in four phases and by how many people each item reaches inside a phase, and the next item. Also each path step, done or not, skipped or not. |
setup_brief | The path | Read | The Get running cards (the quick setup questions), the answers so far and the setup parts they draft. |
setup_brief_submit | The path | Change | Save the owner’s Get running answers. |
setup_list_changes | The path | Read | The agency’s setup changes, newest first, each with who made it and whether it can be undone. |
setup_skip_step | The path | Change | Skip a setup step for now, put it or a Make it yours item off until later, or bring it back. |
setup_undo | The path | Change (asks first for a big one) | Undo one setup change from setup_list_changes. |
setup_data_copy_status | Database | Read | The data copy to the agency’s own database: connected or not, running or paused, and how far it has copied. |
setup_pause_data_copy | Database | Asks first | Pause the data copy to the agency’s own database. |
setup_resume_data_copy | Database | Asks first | Resume a paused data copy. |
setup_get_brand | Brand and web address | Read | The live brand, the draft waiting (if any) and the past versions. |
setup_check_domain | Brand and web address | Read | The agency’s web address: its status and the DNS records it still needs, as last checked. |
setup_save_brand_draft | Brand and web address | Change | Save brand changes as a DRAFT. Members see nothing until it is published. |
setup_discard_brand_draft | Brand and web address | Change | Throw away the brand draft. The live brand stays. |
setup_publish_brand | Brand and web address | Asks first | Publish the brand draft: every member sees it. |
setup_revert_brand | Brand and web address | Asks first | Put an earlier brand version back live. |
setup_connect_domain | Brand and web address | Asks first | Connect the agency’s own web address. The answer gives the DNS records to add. |
setup_set_contact | Contact info | Change | Set the agency’s contact details and the welcome line new agents see. Only the keys given change. |
setup_list_fields | Lead fields and stages | Read | The agency’s lead, policy and agent fields, and its lead stages. |
setup_add_fields | Lead fields and stages | Change | Add up to 20 fields at once. Each one shows on the leads, policies or agents it is for. |
setup_update_field | Lead fields and stages | Change | Change one of the agency’s own fields: its label, hint, choices or whether it is required. |
setup_set_stages | Lead fields and stages | Change | Set the agency’s whole list of lead stages, in order. Every fixed stage needs at least one. |
setup_list_tables | Tables | Read | The agency’s custom tables, each with its fields and record count. |
setup_create_table | Tables | Change | Create a custom table with up to 20 fields, published so agents can use it (or a draft). |
setup_add_table_fields | Tables | Change | Add up to 20 fields to one of the agency’s tables. |
setup_list_supabase_tables | Tables | Read | The tables in the agency’s linked Supabase project (each schema.table with its rough row count), to bring in. |
setup_import_supabase_table | Tables | Change | Bring up to 10 tables in from the agency’s linked Supabase project as draft tables, in the background (we pick a field per column; Social Security, tax id, card, password, secret and token columns are left out). mode "copy" (the default) copies once; "sync" keeps each table in sync every 15 minutes, and then it is read-only in the portal (edit it in Supabase). Sync needs a primary key. |
setup_add_table_view | Tables | Change | Save a named view of a published table (its filters, sort and columns). |
setup_list_roles | Who can do what | Read | The agency’s roles, what each one lets people do, who holds it, and the members. builtIns are Owner, Admin, Assistant and Member: each has its id (builtin:<tier>), what it grants here (grants), its defaults (defaultGrants), changed, and editable — true when this person may change it (only the agency owner; the Owner never). |
setup_save_role | Who can do what | Change (asks first for a big one) | Create a role, or change one, with the switches it turns on. A role grants nothing until someone holds it. To change what the built-in Member tier can do, pass role_id builtin:member and its full new switches. Admin and Assistant are the agency’s own starter roles: change them by their own role_id. |
setup_reset_role | Who can do what | Asks first | Put the built-in Member tier back on today’s defaults. |
setup_assign_role | Who can do what | Asks first | Give a member a role. |
setup_get_comp_chart | Pay chart | Read | The pay chart in force, the draft waiting and its open questions. |
setup_save_comp_draft | Pay chart | Change | Change the draft pay chart’s levels: rank names, codes, tracks and requirements, or how agents between levels are paid. |
setup_publish_comp_chart | Pay chart | Asks first | Publish the draft pay chart: new deals are paid from it. |
setup_revert_comp_chart | Pay chart | Asks first | Put an earlier pay chart version back. |
setup_list_course | Onboarding course | Read | The agency’s onboarding course: its sections and their steps. |
setup_add_course_items | Onboarding course | Change | Add up to 20 steps to the onboarding course, in a section that exists or a new one. |
setup_install_starter_course | Onboarding course | Change | Install the starter onboarding course. Only when the course is empty. |
setup_add_trainings | Weekly trainings | Change | Add up to 20 weekly trainings. |
setup_update_training | Weekly trainings | Change | Change one weekly training. |
setup_get_kit | Starter kit | Read | The agency’s starter kit: each item, shared or not, and its draft. |
setup_save_kit_item | Starter kit | Change | Create a kit item, or save a new draft of one. Nobody gets it until it is shared. |
setup_share_kit_item | Starter kit | Change | Share a kit item for the first time: every agent gets their copy. |
setup_update_kit_item | Starter kit | Asks first | Push the new draft of an already-shared kit item to every agent who has it. |
setup_get_join_link | Invite your team | Read | The agency's two links. team_link is the one to send working agents: they sign up for EZ Leads and join the agency right away. recruit_link is the onboarding form for people being recruited and licensed. Share them exactly as returned. |
setup_set_phone_policy | Extras | Change | Set the agency’s phone rules: which features agents may use and the daily caps. Only the keys given change. |
setup_set_phone_price | Extras | Asks first | Set next month’s price agents pay for a phone service (never under the floor), or put it back at the floor. |
setup_export_config | Extras | Asks first | Export the agency’s whole setup as one file (a backup). |
setup_list_clone_targets | Extras | Read | The agencies below this one that its setup can be copied to. |
setup_clone_setup | Extras | Asks first | Copy this agency’s setup to an agency below it (or send it an offer to accept). |
setup_post_announcement | Extras | Asks first | Post an announcement to every member. |
setup_distribute_leads | Extras | Asks first | Hand out leads from the agency’s lead inventory: the newest count, round-robin or to one agent. |
Embedded apps
Add your own web app in Studio › Apps & AI and it opens inside EZPZ, in the rail, for the people you pick. When someone opens it, EZPZ loads your start URL in a frame with a one-time token in the URL fragment. Your page trades that token for a one-hour session that acts as that person — it sees exactly what they see in EZPZ and can do only what they can.
- Your app must be served over HTTPS, and your server must allow EZPZ to frame it:
Content-Security-Policy: frame-ancestors https://www.buildezpz.com https://buildezpz.com - Read
ezpz_tokenfromlocation.hash, then remove it withhistory.replaceState. - From the browser,
POST /api/v1/auth/exchangewith{ "token": "…" }. The token works once, for 5 minutes, and only from your app's registered origin. Anything else answers401 invalid_token. - Use the
access_tokenyou get back (ezpz_sess_…) asAuthorization: Beareron/api/v1/*. It lasts an hour. - On a
401, post{ type: 'ezpz:token-expired' }tohttps://www.buildezpz.comand wait for{ type: 'ezpz:token', token }, then exchange that token. Checkevent.originon every message. - To move EZPZ to one of its own pages, post
{ type: 'ezpz:navigate', path: '/leads' }. - Never store either token — not in localStorage, a cookie or a log. Keep the session in memory and get a new one when it expires.
// 1. Read the one-time token from the fragment, then remove it from the URL.
const token = new URLSearchParams(location.hash.slice(1)).get('ezpz_token');
history.replaceState(null, '', location.pathname + location.search);
// 2. Exchange it from the browser (the Origin header must be your app's).
const PARENT = 'https://www.buildezpz.com';
async function exchange(t) {
const res = await fetch(PARENT + '/api/v1/auth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: t }),
});
if (!res.ok) throw new Error('exchange failed');
return (await res.json()).access_token; // ezpz_sess_… — keep it in memory only
}
let session = await exchange(token);
// 3. Call the API as the signed-in person.
async function api(path, init = {}) {
const res = await fetch(PARENT + '/api/v1' + path, {
...init,
headers: { ...init.headers, Authorization: 'Bearer ' + session },
});
if (res.status !== 401) return res;
session = await renew(); // 4. On 401, ask EZPZ for a fresh token, then retry once.
return fetch(PARENT + '/api/v1' + path, { ...init, headers: { ...init.headers, Authorization: 'Bearer ' + session } });
}
function renew() {
return new Promise((resolve) => {
window.addEventListener('message', async function onMessage(e) {
if (e.origin !== PARENT || e.data?.type !== 'ezpz:token') return;
window.removeEventListener('message', onMessage);
resolve(await exchange(e.data.token));
});
parent.postMessage({ type: 'ezpz:token-expired' }, PARENT);
});
}
// Move EZPZ itself to one of its pages:
// parent.postMessage({ type: 'ezpz:navigate', path: '/leads' }, PARENT);A live, one-way copy of your agency's data in your own Postgres, under ezpz_v1. Set it up in Studio › Data copy.
What the copy is
EZPZ can keep a live copy of your agency’s data in a Postgres database you own. Changes in EZPZ reach your copy within seconds. The copy is one-way: nothing you write in your database comes back.
EZPZ stays the system of record. Make changes in EZPZ; read, join and report in your copy. The copy holds the same records and fields as API v1, under the schema ezpz_v1, ids included.
Connect it in Studio › Data copy. It is included in the Agency plan, and anyone whose role holds the Data copy switch can manage it.
Connect your database
- Postgres 14 or newer.
- SSL on. A connection string with
sslmode=disableis refused. - On Supabase, paste the pooler connection string (
…pooler.supabase.com), not the directdb.<project>.supabase.cohost. Use a paid project: a free project pauses when idle, and a paused project cannot be copied to. - If your database has a firewall, allow the two addresses Studio › Data copy shows (one IPv4, one IPv6).
Paste a connection string for a user that can create a schema and a role. We use it once: we create the schema ezpz_v1 and a role ezpz_copier with its own password, keep that role’s connection sealed, and discard yours. ezpz_copier owns ezpz_v1 and can reach nothing else in your database.
Studio runs the checks and shows each one, with the fix beside any that fail. Then the first copy starts.
Tables
One table per resource, in ezpz_v1. id is the public id (ld_…, ag_…) and the primary key; references to other records are public ids too. Every public field is a column, typed:
text— text, a fixed set of words.bigint— whole numbers, money in cents.numeric— decimals.boolean— true / false.timestamptz— times.date— dates.jsonb— objects, lists.
Custom values are in custom, keyed by each field’s key. Records of a custom table keep their values in records.data.
| Table | Columns | Bookkeeping |
|---|---|---|
ezpz_v1.leads | id text, owner_id text, first_name text, last_name text, email text, phone text, state text, zip text, age bigint, disposition text, stage jsonb, source text, product_type text, lead_type jsonb, tags jsonb, notes text, score bigint, score_warmth_label text, call_count bigint, dial_attempts bigint, voicemails_left bigint, callback_at timestamptz, closed_at timestamptz, last_touched_at timestamptz, last_dialed_at timestamptz, last_inbound_at timestamptz, cost_cents bigint, campaign_id text, origin_channel text, referred_by_lead_id text, custom jsonb, created_at timestamptz, updated_at timestamptz | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
ezpz_v1.policies | id text, lead_id text, owner_id text, ap_cents bigint, advanced_pay_cents bigint, commission_cents bigint, commission_pct numeric, face_amount_cents bigint, carrier_name text, carrier_product text, policy_number text, policy_stage text, policy_stage_at timestamptz, close_date date, effective_date date, sold_reason text, policy_note text, client_first_name text, client_last_name text, call_id text, custom jsonb, created_at timestamptz, updated_at timestamptz | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
ezpz_v1.appointments | id text, lead_id text, owner_id text, starts_at timestamptz, ends_at timestamptz, timezone text, status text, outcome text, title text, notes text, source text, created_by text, created_at timestamptz, updated_at timestamptz | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
ezpz_v1.calls | id text, lead_id text, owner_id text, direction text, status text, started_at timestamptz, answered_at timestamptz, ended_at timestamptz, duration_sec bigint, voicemail boolean, from_number text, to_number text, caller_name text, placed_by_actor_id text, answered_by text, recording_duration_sec bigint, has_recording boolean, transcription text, recording_link text, created_at timestamptz, recording_object text, recording_gone_at timestamptz | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
ezpz_v1.messages | id text, lead_id text, owner_id text, direction text, body text, status text, from_number text, to_number text, num_media bigint, send_origin text, sent_by_kind text, sent_by_actor_id text, read_at timestamptz, created_at timestamptz | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
ezpz_v1.agents | id text, full_name text, display_name text, business_name text, email text, phone text, agency_id text, role text, manager_id text, npn text, timezone text, work_state text, avatar_url text, status text, custom jsonb, created_at timestamptz | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
ezpz_v1.roles | id text, agency_id text, name text, description text, switches jsonb, push_mode text, status text, created_at timestamptz | _xact, _seq, _synced_at, deleted_at |
ezpz_v1.fields | id text, agency_id text, object text, key text, label text, type text, options jsonb, required boolean, help text, position bigint, push_mode text, status text, created_at timestamptz | _xact, _seq, _synced_at, deleted_at |
ezpz_v1.stages | id text, agency_id text, key text, label text, maps_to text, tone text, position bigint, push_mode text, status text, created_at timestamptz | _xact, _seq, _synced_at, deleted_at |
ezpz_v1.tables | id text, agency_id text, key text, name text, record_noun text, icon text, shared boolean, on_lead boolean, status text, fields jsonb | _xact, _seq, _synced_at, deleted_at |
ezpz_v1.records | id text, table_id text, agency_id text, owner_id text, title text, data jsonb, links jsonb, created_at timestamptz, updated_at timestamptz | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
ezpz_v1.agencies | id text, name text, parent_id text, tier text, created_at timestamptz | _xact, _seq, _synced_at, deleted_at |
ezpz_v1.kpi_days | id text, agent_id text, date date, dials bigint, contacts bigint, appointments_set bigint, appointments_held bigint, presentations bigint, closes bigint, texts_sent bigint, talk_seconds bigint, leads_received bigint, ap_cents bigint, spend_cents bigint, lead_cost_cents bigint, cost_per_lead_cents bigint, cost_per_close_cents bigint | _xact, _seq, _synced_at, deleted_at, departed_at, moved_at, reason |
Bookkeeping columns
Every table also has:
_xact— With_seq: the position in EZPZ’s change log this row was last written at._seq— An older change never overwrites a newer one, and applying the same change twice writes nothing._synced_at— When this row was last written in your copy.deleted_at— Set when the record was deleted in EZPZ. The row stays.
Tables of records an agent owns (leads, policies, appointments, calls, messages, agents, records, kpi_days) also have:
departed_at— Set when the agent who owns it left your agency. The row stays and stops updating.moved_at— Set when the record moved to an agent outside your copy. The row stays and stops updating.reason— Why it stopped updating:departed,movedorconsent_withdrawn.
Deletes, departures and moves
We never delete a row in your database. What happened to a record is a column on its row:
- Deleted in EZPZ:
deleted_atis set (a tombstone). - Its agent left your agency:
departed_atis set andreasonisdeparted. The row stops updating. - Moved to an agent outside your copy, or to one who has not accepted:
moved_atis set andreasonismoved. The row stops updating. - If the record comes back to your copy, these clear and it updates again.
select *
from ezpz_v1.leads
where deleted_at is null
and departed_at is null
and moved_at is null;Typed views
Custom fields become columns in views we keep beside the tables: leads_typed, policies_typed, agents_typed, and one records_<table key> per custom table. Each field key is a column, cast by its type:
- number, currency →
numeric - date →
date - checkbox →
boolean - multi_select →
text[] - everything else →
text
A field key that matches an existing column is named custom_<key>. The views are rebuilt when a field or table changes; the field and table definitions themselves are in fields and tables.
Lag and sync_meta
ezpz_v1.sync_meta has one row that tells you how current your copy is:
model_version— The version of the table layout your copy has.last_synced_at— When the copy last caught up with every change.lag_ms— Milliseconds between a change in EZPZ and it landing here, for the latest batch.backlog— Changes waiting to copy.status— The copy’s state, as Studio shows it.backfill_done_at— When the first (or the latest full) copy finished.worker_version— The version of the program that wrote it.updated_at— When this row was last written.
select status, last_synced_at, lag_ms, backlog
from ezpz_v1.sync_meta;Studio › Data copy shows the same. If the copy falls more than 15 minutes behind, or cannot reach your database again and again, the owner gets an email and a notice.
Recordings
Choose in Studio › Data copy: Off, Links (the default) or Your bucket.
Links. recording_link on each row is a stable link, https://www.buildezpz.com/api/v1/calls/{id}/recording. Request it with an API key whose roles may listen to that agent’s recordings (calls.listen). It answers with a redirect to the audio, good for 10 minutes, or 404 when the audio is gone.
Your bucket. Each recording is also copied into your S3-compatible bucket (AWS S3, Cloudflare R2, Backblaze B2 and others) as ezpz-recordings/cl_….mp3 (or under the path prefix you set), and recording_object names it. recording_gone_at is set when the audio was gone before it could copy. We check the bucket the first time a recording copies; a failure shows in Studio and alerts the owner.
A bucket copy is kept by you, outside EZPZ’s deletion schedule. When a recording is removed in EZPZ we delete the object we copied into your bucket; copies you moved elsewhere are yours to delete.
Whose records copy
Your agency and every agency under it are included. agents, roles, fields, stages, tables, agencies, kpi_days copy whether or not an agent accepted: your roster, its production totals and your definitions.
Record rows (leads, policies, appointments, calls, messages, records) copy only for agents who accepted the data copy notice. Each agent is asked the next time they sign in; Studio › Data copy shows how many of each agency have accepted.
Studio’s What copies switches turn whole groups off: leads with their deals and activity; conversations with their transcripts; agents with their roles, custom tables and records. Production totals, fields, stages and agencies always copy.
Pause, outages and re-copy
- Pause. Changes wait up to 7 days. Resume and what waited copies first.
- Can’t reach your database. We keep retrying, waiting longer each time. After 7 days the copy pauses and asks for a full re-copy, because changes older than 7 days are no longer kept.
- Re-copy all. Every record copies again from the start. Rows already in your database are updated in place, never deleted.
- Disconnect. Copying stops and we erase the connection. What is already in your database stays there.
Versioning
Changes to ezpz_v1 are additive: a new table or a new column, applied to your copy automatically. Nothing is ever dropped or retyped. A breaking change ships ezpz_v2 beside ezpz_v1.
What we never do
- Read your data. Outside setup (the server version, SSL, our role’s own rights) the one thing we read is
ezpz_v1.sync_meta. - Delete a row or drop a table in your database.
- Touch anything outside
ezpz_v1. - Show your connection string or bucket keys again. Both are stored sealed.
Versioning
The version is in the path: /api/v1. Changes inside v1 are additive only — a new resource, a new field, a new event or a new enum value. Ignore fields you do not know.
A breaking change — a field removed or renamed, or its type or meaning changed — ships /api/v2 and ezpz_v2 beside v1, with a deprecation window.