API & Zapier

Import and export leads, create and launch campaigns, put properties on your websites, and receive webhooks for everything that happens next. Works with Zapier's built-in Webhooks by Zapier — no extra app needed.

Authentication

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…
An API key is a password for your whole account. Keep it in server-side code or Zapier's header field — never in a public web page, and never in a URL.

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

Basics

Endpoints

EndpointWhat it does
GET/meKey + account check (name, plan).
GET/contactsList / export leads with filters + pagination.
POST/contactsCreate 1–100 leads with duplicate handling.
GET/contacts/:idOne contact.
PATCH/contacts/:idUpdate fields, add/remove tags, append a note.
POST/contacts/:id/enrollAdd the contact to a campaign (membership only — see below).
GET/campaignsYour campaigns (add ?include_groups=true for multi-channel groups).
POST/campaignsCreate a text, email and/or cold-call campaign with its message.
GET/campaigns/:idOne campaign: members and message counts (queued / sent / failed).
POST/campaigns/:id/contactsAdd up to 100 contacts (created or matched) or 500 ids to a campaign.
POST/campaigns/:id/launchTurn the campaign on and start everyone in it.
POST/campaigns/:id/pauseStop sending until it's launched again.
GET/deals, /deals/:idRead-only deals.
GET/properties, /properties/:idProperties (filter ?external_id=, ?inventory_status=).
POST/propertiesCreate a property (or update the one with your external_id).
PATCH/properties/:idUpdate a property.
POST/properties/:id/photosAdd up to 10 photos from public https image links.
GET/sitesYour websites and their addresses.
GET/sites/:id/listingsThe properties on a website, with each page's link.
POST/sites/:id/listingsPut a property on a website — returns its page link.
PATCH / DELETE/sites/:id/listings/:listing_idChange a listing's text, or take it off the site.

Your own ids (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.

GET /contacts — export leads

Query parameters (all optional):

ParamMeaning
roleseller, buyer, both, or other
statuspending, contacted, replied, interested, qualified, active, followup (Follow up later), notinterested, or badcontact (Bad contact info)
tagExact tag match
marketExact market match
created_since / updated_sinceISO 8601 timestamp, e.g. 2026-08-01T00:00:00Z — for incremental syncs and polling
qName contains (min 2 characters)
external_idThe contact with your id
limit / offsetPage 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.

POST /contacts — import leads

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"
}

PATCH /contacts/:id

{ "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.

POST /contacts/:id/enroll

{ "campaign_id": "…uuid from GET /campaigns…" }
Enrollment is membership only — it adds the contact to the campaign's member list, but does not queue a message by itself (unless the campaign has Continuous sending on). To start people, use 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.

Campaigns: create, fill, launch

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

POST /campaigns — create

{
  "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…" }
}

POST /campaigns/:id/contacts — add contacts

{
  "contacts": [
    { "external_id": "buyer-881", "name": "Sam Buyer", "role": "buyer",
      "phone": "(512) 555-0188", "email": "sam@example.com" }
  ],
  "on_duplicate": "skip"
}

POST /campaigns/:id/launch — start

{ "start_at": "2026-11-02T15:00:00Z" }   // optional — default: now

Properties and websites

POST /properties

{
  "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.

POST /properties/:id/photos

{ "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.

POST /sites/:id/listings — a property page

{
  "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"]
}

Webhooks (Zapier triggers)

Webhooks push events to your URL the moment they happen. Manage them in Settings → API & Zapier. Events:

EventFires when
contact.createdA contact is created — from any source (API, CSV import, website lead form, Property Records, manual).
contact.repliedA 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.qualifiedA 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_outA contact opts out of email or sms (the payload names the channel).
message.sentA 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.receivedA contact texted or emailed in — every message, with its text. Automatic email replies (out-of-office and similar) are left out.
message.failedA queued campaign message failed for good (failure.reason: "send_failed", with the error), or an email bounced ("bounced", with the address).
call.loggedA 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.clickedA contact clicked a tracked link in a campaign message — the first click per link. (Email opens aren't tracked.)
form.submittedSomeone 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:

Verifying signatures (optional)

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]))
}

Zapier recipes

Trigger: a new Direct REI lead starts your Zap

Action: your Zap creates a Direct REI lead

Recipe: a deal blast from your own CRM

  1. Once: in Settings → API & Zapier, add a webhook to your system for Message received, Call logged, Link clicked, Website form and Opted out.
  2. Per deal: 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.
  3. Replies, call outcomes, clicks and offers arrive at your webhook, each naming your buyer's external_id and the campaign.

Alternative trigger: polling

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