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 (add ?include_groups=true for multi-channel groups). |
POST/campaigns | Create a text, email and/or cold-call campaign with its message. |
GET/campaigns/:id | One campaign: members and message counts (queued / sent / failed). |
POST/campaigns/:id/contacts | Add up to 100 contacts (created or matched) or 500 ids to a campaign. |
POST/campaigns/:id/launch | Turn the campaign on and start everyone in it. |
POST/campaigns/:id/pause | Stop sending until it's launched again. |
GET/deals, /deals/:id | Read-only deals. |
GET/properties, /properties/:id | Properties (filter ?external_id=, ?inventory_status=). |
POST/properties | Create a property (or update the one with your external_id). |
PATCH/properties/:id | Update a property. |
POST/properties/:id/photos | Add up to 10 photos from public https image links. |
GET/sites | Your websites and their addresses. |
GET/sites/:id/listings | The properties on a website, with each page's link. |
POST/sites/:id/listings | Put a property on a website — returns its page link. |
PATCH / DELETE/sites/:id/listings/:listing_id | Change a listing's text, or take it off the site. |
external_id)Contacts and properties take an external_id: your id for the record in your own system (up to 100 characters, unique per account). It comes back on every read and in every webhook, so you can match events to your records without storing our ids.
external_id, we look for that id first, then for the same email or phone. A contact found by email/phone that has no external_id yet gets yours; one that already has a different id keeps it (a warning says so — an id is never moved silently).GET /contacts?external_id=… or GET /properties?external_id=….external_id, or takes the other one's when it had none.Query parameters (all optional):
| Param | Meaning |
|---|---|
role | seller, buyer, both, or other |
status | pending, contacted, replied, interested, qualified, active, followup (Follow up later), notinterested, or badcontact (Bad contact info) |
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) |
external_id | The contact with your id |
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", "austin"],
"mailing_street": "100 Main St", "mailing_city": "Austin",
"mailing_state": "TX", "mailing_zip": "78701",
"notes": "Asked about the Travis Co. lot"
}
["website-form", "austin"]) or as one text value separated by commas, semicolons or | ("website-form, austin") — the text form is what Zapier sends. Repeats are removed (letter case doesn't matter).name, email, phone. Other accepted fields: external_id (your own id), company, title, contact_type, website, social_links (up to 6 public profile links — LinkedIn, Facebook, Instagram and so on — as an array or one string), 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/interested/qualified/active/followup/notinterested) are accepted with a warning: automated campaign sends skip engaged contacts.external_id first, then by email (case-insensitive) and by phone in any format ((512) 555-0134 ≡ 5125550134 ≡ +15125550134). on_duplicate: "skip" (default — returns the existing contact's id), "update" (applies your fields to the existing contact and adds your tags to the ones it already has — an update never removes a tag), 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: external_id (null clears it; 409 external_id_taken if another contact has it), name, email, phone, company, title, contact_type, market, website, social_links (replaces the list; send [] to clear it), mailing_*, status, role, plus add_tags / remove_tags (a list or one comma-separated text value, up to 10 each) 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…" }
POST /campaigns/:id/launch. To start imported leads automatically, create an automation in the app: Tasks → Automations → When a new lead is added + only if Source is api → Enroll in campaign. The automation enrolls AND queues the first message with every compliance gate applied. Note: enrolling a contact into a campaign with AI auto-replies on arms those replies for that contact.Launch outreach from your own system in three calls. Everything runs through the same sending as the app: your mailboxes and numbers, your send hours, spacing between sends, opt-outs, bounces, and "never send the same message twice".
{
"name": "123 Oak St — buyers",
"channels": ["sms", "email"],
"deal_blast": true,
"property_id": "…uuid from POST /properties…",
"sms": { "body": "Hi [First Name], new deal: 3/2 at 123 Oak St, $145k. Want the details?" },
"email": { "subject": "New deal: 123 Oak St", "body": "Hi [First Name],\n\n…" }
}
channels: any of sms, email, cold_call (a cold-call campaign takes "cold_call": {"script": "…"}). One channel creates one campaign; several create a group with one campaign per channel (named "123 Oak St — buyers — Text" and so on). Use the group's id for every call below."sms": {"messages": [{"body": "…"}, {"body": "…", "wait_days": 3}]} — up to 12 messages; wait_days is days after the previous message (default 4). The first email needs a subject.[First Name], [Full Name], [Company], [Market], and [First Name|there] for a backup word.deal_blast: true is for sending a property to your buyers: buyers marked Active, Qualified or Replied still get it (only Not interested is skipped), and deal blasts don't count toward your plan's campaign limit. A deal blast is one message per channel, by text and/or email, about one property — property_id is required (422 invalid_deal_blast otherwise). For a sequence or for calls, create an ordinary campaign (no deal_blast). Leave it off for normal cold outreach, where anyone already engaged is skipped.ai_replies: "off" (default — replies come to you and your webhooks), "draft" (the AI writes a reply for you to approve), or "auto".201 with the campaign (and its channel campaigns). A name already in use answers 409 name_taken.{
"contacts": [
{ "external_id": "buyer-881", "name": "Sam Buyer", "role": "buyer",
"phone": "(512) 555-0188", "email": "sam@example.com" }
],
"on_duplicate": "skip"
}
contacts (up to 100 objects — created, or matched by external_id / email / phone exactly like POST /contacts), and/or contact_ids and external_ids for contacts already in Direct REI (up to 500 together).added, how many were already_in the campaign, which ids weren't found, and each contact's result (with its external_id)."start": true to queue message 1 for these contacts right away (used when the campaign is already running). A campaign with no first message adds them but doesn't start them (the response's started[].error says so).{ "start_at": "2026-11-02T15:00:00Z" } // optional — default: now
members, queued_now, already_started, and how many were skipped (opted out or no usable email/number; already engaged; already queued). Up to 5,000 are queued per call — if more_left is true, call launch again; people already queued are skipped, so launching twice never sends twice.sms_consent_required and nothing changes. A campaign without a first message answers 422 message_missing.launch_failed with the reason, and the campaign is left paused. If part of it was queued, the response has "partial": true and each failed channel's error — calling launch again is safe.POST /campaigns/:id/pause stops it; queued messages wait until you launch again.{
"external_id": "deal-2041",
"address": "123 Oak St", "city": "Austin", "state": "TX", "zip": "78701",
"county": "Travis", "parcel_id": "0123456",
"property_type": "sfr", "beds": "3", "baths": "2", "sqft": "1450", "lot_size": "0.25",
"asking_price": "145000", "arv": "230000",
"inventory_status": "under_contract",
"owner_external_id": "seller-77"
}
With an external_id that already exists, the call updates that property instead (so it's safe to send again). Other fields: market, zoning, list_price, estimated_value, notes, legal_description, tags, owner_contact_id. inventory_status is one of "", under_contract, owned, listed, sold.
{ "urls": ["https://cdn.example.com/oak-front.jpg", "https://cdn.example.com/oak-kitchen.jpg"] }
We download each image (public https links only — never a private network address — JPG / PNG / WebP, up to 8 MB) and store it with the property. The first photo becomes the cover. Up to 15 photos per property.
{
"property_external_id": "deal-2041",
"title": "3/2 Ranch — Below Market",
"price": "$145,000",
"description": "**Cash buyers only.** Roof 2019, needs cosmetic work.",
"facts": ["3 bed / 2 bath", "1,450 sqft", "0.25 acre lot"]
}
GET /sites. Sending the same property again updates its page.url. On a published website the page is live within about 5 minutes; on a draft it goes live when you publish the site in the app.form.submitted webhook naming the listing — set the property page's form heading to "Make an offer" once in the app's website builder.subtitle, taxes, video_url (YouTube or Vimeo). PATCH changes them; DELETE takes the page down.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 or interested (a Dialer "Interested") from an earlier stage — once, not again while they stay replied, interested, qualified or active. |
contact.qualified | A contact's status becomes qualified — set by a person, by the AI from a reply, through the API, or a contact created already qualified. The payload adds previous_status (the status it moved from; null when it was created qualified). It fires again only if the contact leaves qualified and comes back. |
contact.opted_out | A contact opts out of email or sms (the payload names the channel). |
message.sent | A text or email went out to a contact — a campaign message, one sent by hand, or an AI reply (message.ai_reply says which). |
message.received | A contact texted or emailed in — every message, with its text. Automatic email replies (out-of-office and similar) are left out. |
message.failed | A queued campaign message failed for good (failure.reason: "send_failed", with the error), or an email bounced ("bounced", with the address). |
call.logged | A call landed on the contact's timeline: a Dialer outcome (call.result is "Interested", "No answer", "Voicemail", "Not interested", "Bad number"…), an AI call's result, or a missed / answered inbound call. |
link.clicked | A contact clicked a tracked link in a campaign message — the first click per link. (Email opens aren't tracked.) |
form.submitted | Someone filled in a form on one of your websites — with what they typed, and the property page it came from (listing, including your property external_id). |
Messages and calls dated more than 3 days ago (history imports) never send events. Carriers don't report text delivery to us, so there is no "delivered" event — "sent" and "failed" are what we know.
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": "…", "external_id": "your-crm-id-123", "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": "…"
}
}
contact.opted_out adds "channel": "email" or "sms"; contact.qualified adds "previous_status": "replied" (or whichever status it moved from). The four contact.* events carry the contact as data.
The other events carry data.contact (the same fields, including external_id), data.campaign (id, name, channel, group_id — or null), and one more object:
// message.received
{
"id": "delivery-uuid", "event": "message.received", "created_at": "…",
"data": {
"contact": { "id": "…", "external_id": "buyer-881", "name": "Sam Buyer", … },
"campaign": { "id": "…", "name": "123 Oak St — buyers — Text", "channel": "sms", "group_id": "…" },
"message": { "id": "…", "channel": "sms", "direction": "inbound", "subject": null,
"body": "Yes — what's the address?", "ai_reply": false, "mailbox": null,
"from": "+15125550188", "to": "+15125550199", "at": "…" }
}
}
// call.logged → "call": { "direction", "result", "outcome", "note", "from", "to", "logged_at" }
// message.failed → "failure": { "reason", "channel", "step", "detail" | "address", "at" }
// link.clicked → "link": { "url", "label", "channel", "step", "clicked_at" }
// form.submitted → "site", "listing", "form": { "type", "fields": { "name", "email", "phone", "message" },
// "answers", "consent_to_texts", "page_url", "submitted_at" }
Email body is the message as received and may include the quoted earlier message under the reply.
Delivery behavior:
contact.qualified-only webhook gets a qualified sample with previous_status).PATCH that sets status to qualified sends contact.qualified to your own subscription.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… For several tags, type them in the one tags field separated by commas (zillow, hot, tx-land).POST /properties (with your deal id as external_id) → optional POST /properties/:id/photos and POST /sites/:id/listings for a public page → POST /campaigns with "deal_blast": true, the property_id and the page link in the message (for calls, a separate cold-call campaign) → POST /campaigns/:id/contacts with your matched buyers (your buyer ids as external_id) → POST /campaigns/:id/launch.external_id and the campaign.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