Create an API key in the app: Settings → API & Zapier → + Create API key (paid plans; account owner only). The full key is shown once — if you lose it, revoke it and create a new one. Send it on every request:
Authorization: Bearer drei_0b12c…
Base URL:
https://vrgnjfatqasljgzrhyub.supabase.co/functions/v1/api/v1
Quick check that a key works (also what Zapier connection tests should call):
curl -H "Authorization: Bearer drei_..." \
https://vrgnjfatqasljgzrhyub.supabase.co/functions/v1/api/v1/me
Content-Type: application/json).Retry-After header and retry_after_seconds in the body.{"error": "code", "message": "human explanation"} with a matching HTTP status (400, 401, 403, 404, 405, 413, 422, 429, 500).api_requires_paid_plan.{"total_count": n, "rows": [...], "has_more": true, "limit": 50, "offset": 0}. Page with limit (max 200) + offset.| Endpoint | What it does |
|---|---|
GET/me | Key + account check (name, plan). |
GET/contacts | List / export leads with filters + pagination. |
POST/contacts | Create 1–100 leads with duplicate handling. |
GET/contacts/:id | One contact. |
PATCH/contacts/:id | Update fields, add/remove tags, append a note. |
POST/contacts/:id/enroll | Add the contact to a campaign (membership only — see below). |
GET/campaigns | Your campaigns (ids for enrolling). |
GET/deals, /deals/:id | Read-only deals. |
GET/properties, /properties/:id | Read-only properties. |
Query parameters (all optional):
| Param | Meaning |
|---|---|
role | seller, buyer, both, or other |
status | pending, contacted, replied, qualified, active, or notinterested |
tag | Exact tag match |
market | Exact market match |
created_since / updated_since | ISO 8601 timestamp, e.g. 2026-08-01T00:00:00Z — for incremental syncs and polling |
q | Name contains (min 2 characters) |
limit / offset | Page size (max 200, default 50) / start position |
Rows are ordered newest-updated first and include more than the in-app CSV export: extra phones/emails, notes, mailing address, opt-out timestamps, tags, and source.
Send one contact object, an array of up to 100, or {"contacts": [...], "on_duplicate": "skip"}:
{
"name": "Jane Seller",
"role": "seller",
"email": "jane@example.com",
"phone": "(512) 555-0134",
"market": "Austin, TX",
"tags": ["website-form"],
"mailing_street": "100 Main St", "mailing_city": "Austin",
"mailing_state": "TX", "mailing_zip": "78701",
"notes": "Asked about the Travis Co. lot"
}
name, email, phone. Other accepted fields: company, title, contact_type, website, additional_emails / additional_phones (up to 2 each), status.role defaults to seller; status defaults to pending. Both are strictly validated — an invalid value is a per-item error, never silently changed. Engaged statuses (replied/qualified/active/notinterested) are accepted with a warning: automated campaign sends skip engaged contacts.on_duplicate: "skip" (default — returns the existing contact's id), "update" (applies your fields to the existing contact), or "create" (allows the duplicate).{"created": 2, "updated": 0, "skipped": 1, "errors": 0, "results": [{"index": 0, "id": "…", "status": "created"}, …]}.crm_limit_reached errors.source: "api", and creating them fires your New lead automations and webhooks like any other lead.{ "status": "qualified", "add_tags": ["hot"], "remove_tags": ["cold"], "append_note": "Called back — motivated." }
Updatable fields: name, email, phone, company, title, contact_type, market, website, mailing_*, status, role, plus add_tags / remove_tags and append_note (notes are append-only through the API — it never overwrites your existing notes). Opt-out flags and compliance fields are not writable.
{ "campaign_id": "…uuid from GET /campaigns…" }
Webhooks push events to your URL the moment they happen. Manage them in Settings → API & Zapier. Events:
| Event | Fires when |
|---|---|
contact.created | A contact is created — from any source (API, CSV import, website lead form, Property Records, manual). |
contact.replied | A contact's status becomes replied. |
contact.opted_out | A contact opts out of email or sms (the payload names the channel). |
Each delivery is one JSON object per POST (never an array — Zapier Catch Hooks split arrays into multiple runs, so we don't send them):
{
"id": "delivery-uuid",
"event": "contact.created",
"created_at": "2026-08-24T18:00:00Z",
"data": {
"id": "…", "name": "Jane Seller", "email": "jane@example.com",
"phone": "(512) 555-0134", "company": "", "role": "seller",
"status": "pending", "market": "Austin, TX", "tags": ["website-form"],
"source": "api", "mailing_street": "…", "mailing_city": "…",
"mailing_state": "…", "mailing_zip": "…",
"created_at": "…", "updated_at": "…"
}
}
Delivery behavior:
contact.created event any time.Every delivery carries X-Drei-Event, X-Drei-Delivery, and X-Drei-Signature: t=<unix seconds>,v1=<hex>, where v1 is an HMAC-SHA256 of t + "." + rawBody using your webhook's signing secret (viewable via the Secret button in Settings). Zapier Catch Hooks can't check signatures without a Code step — that's fine; verification is optional. On your own server:
// Node.js
const crypto = require('crypto')
function verify(secret, header, rawBody, toleranceSec = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || '')
if (!m) return false
if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false
const expected = crypto.createHmac('sha256', secret)
.update(m[1] + '.' + rawBody).digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]))
}
/contacts.json — it defaults to form, which the API rejects with a hint. This is the #1 setup mistake.Authorization = Bearer drei_….name, email, phone, market, tags…If you'd rather poll than receive webhooks, call GET /contacts?updated_since=<last check> on a schedule — rows are newest-first with stable ids.
Questions? info@directrei.com