Wire SureContact lead capture into any client project in code, so a web form, intake, or booking upserts the lead into SureContact, adds it to a list, and tags it — reliably, with no Ottokit / no-code middleman. Drop the reusable module into any repo; everything client-specific is an env var.
Undefined array key 0), it has no update API (every change = rebuild + new webhook URL), and it fails silently. A ~120-line module you configure with env vars is reusable across every client and just works.
| Base URL | https://api.surecontact.com/api/v1/public |
|---|---|
| Auth | header X-API-Key: <key> (NOT Bearer) |
| Content | Content-Type: application/json, Accept: application/json |
| Docs | https://api.surecontact.com/docs |
| Key guide | surecontact.com/docs/surecontact-api-keys-guide |
| Action | Method | Path | Body |
|---|---|---|---|
| Upsert contact | POST | /contacts/upsert | {"primary_fields":{"email","first_name","last_name"}} |
| Add to list(s) | POST | /contacts/{uuid}/lists/attach | {"list_uuids":["…"]} |
| Apply tag(s) | POST | /contacts/{uuid}/tags/attach | {"tag_uuids":["…"]} |
| Remove from list | POST | /contacts/{uuid}/lists/detach | {"list_uuids":["…"]} |
| Remove tag | POST | /contacts/{uuid}/tags/detach | {"tag_uuids":["…"]} |
| Find by email | GET | /contacts/email/{email} | — |
| List all lists | GET | /lists | — |
| List all tags | GET | /tags | — |
| List contacts | GET | /contacts?limit=1 | — (auth test) |
| Update contact | PUT | /contacts/{uuid} | contact fields |
| Delete contact | DELETE | /contacts/{uuid} | — |
primary_fields, NOT a flat {email} (flat → 422); (2) list/tag attach is /contacts/{uuid}/lists/attach + /tags/attach with array bodies (list_uuids / tag_uuids), NOT /lists/{uuid}/contacts-add. Trust this table, not the docs page. Response record UUID is at data.uuid.
data.uuid → attach list(s) → attach tag(s).Get real list/tag UUIDs from GET /lists and GET /tags (the UUIDs shown in a no-code tool like Ottokit are the same SureContact-native UUIDs).
.env.SURECONTACT_API_KEY= # required — the public API key
SURECONTACT_LEADS_LIST_UUID= # list to add leads to
SURECONTACT_NEW_LEAD_TAG_UUID=# tag to apply on capture
# optional override; defaults to the public base URL:
# SURECONTACT_API_URL=https://api.surecontact.com/api/v1/public
SURECONTACT_API_KEY is unset, so it's safe to leave wired into a project that hasn't been set up yet.
Save as src/app/api/lib/surecontact-crm.ts (adjust path to your project). It is fire-and-forget: a CRM outage never breaks the form.
/**
* SureContact CRM — lead capture via the public REST API.
* Reusable across client projects. Fire-and-forget: never breaks the form.
* API base: https://api.surecontact.com/api/v1/public · auth: X-API-Key
*/
const SC_BASE =
process.env.SURECONTACT_API_URL || "https://api.surecontact.com/api/v1/public"
function scHeaders(key: string): Record<string, string> {
return { "X-API-Key": key, "Content-Type": "application/json", Accept: "application/json" }
}
async function scPost(key: string, path: string, body: unknown): Promise<any> {
const res = await fetch(`${SC_BASE}${path}`, {
method: "POST", headers: scHeaders(key), body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(`SureContact ${path} → ${res.status} ${text.slice(0, 200)}`)
}
return res.json().catch(() => ({}))
}
/** Upsert a contact and return its UUID. Fields go under primary_fields. */
export async function upsertSureContact(
key: string, email: string, firstName?: string, lastName?: string
): Promise<string | null> {
const data = await scPost(key, "/contacts/upsert", {
primary_fields: { email, first_name: firstName || "", last_name: lastName || "" },
})
return data?.data?.uuid || data?.uuid || null
}
export interface SureContactLead {
email: string
firstName?: string
lastName?: string
listUuids?: string[] // defaults to SURECONTACT_LEADS_LIST_UUID
tagUuids?: string[] // defaults to SURECONTACT_NEW_LEAD_TAG_UUID
}
/** Upsert + add-to-list + tag. Logs and swallows every error. No-op without a key. */
export function captureSureContactLead(lead: SureContactLead): void {
const key = process.env.SURECONTACT_API_KEY
if (!key || !lead.email) return
const lists = lead.listUuids ??
([process.env.SURECONTACT_LEADS_LIST_UUID].filter(Boolean) as string[])
const tags = lead.tagUuids ??
([process.env.SURECONTACT_NEW_LEAD_TAG_UUID].filter(Boolean) as string[])
void (async () => {
const uuid = await upsertSureContact(key, lead.email, lead.firstName, lead.lastName)
if (!uuid) {
console.error("⚠️ SureContact: upsert returned no contact UUID for", lead.email)
return
}
const jobs: Promise<unknown>[] = []
if (lists.length) jobs.push(scPost(key, `/contacts/${uuid}/lists/attach`, { list_uuids: lists }))
if (tags.length) jobs.push(scPost(key, `/contacts/${uuid}/tags/attach`, { tag_uuids: tags }))
const results = await Promise.allSettled(jobs)
results
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.forEach((r) => console.error("⚠️ SureContact list/tag error (ignored):", r.reason))
})().catch((err) => console.error("⚠️ SureContact lead capture error (ignored):", err))
}
import { captureSureContactLead } from "@/app/api/lib/surecontact-crm"
export async function POST(req: NextRequest) {
const data = await req.json()
const parts = String(data.name || "").trim().split(" ")
// Fire-and-forget — do NOT await; the form must return even if the CRM is down.
captureSureContactLead({
email: data.email,
firstName: parts[0],
lastName: parts.slice(1).join(" "),
// Omit listUuids/tagUuids to use the env defaults, or pass per-call:
// listUuids: ["<list-uuid>"], tagUuids: ["<tag-uuid>"],
})
// ... rest of the handler
}
For a restaurant client, the same call captures a reservation/waitlist lead — just point the env UUIDs at that client's "Leads" list and "New Lead" tag.
KEY="<paste public api key>"
# 200 + JSON = key valid. 401 API_KEY_INVALID = wrong/stale key (regenerate).
curl -s -w "\nHTTP:%{http_code}\n" -H "X-API-Key: $KEY" -H "Accept: application/json" \
"https://api.surecontact.com/api/v1/public/contacts?limit=1"
# Upsert a test contact (note primary_fields nesting) → returns data.uuid:
curl -s -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"primary_fields":{"email":"test+sc@example.com","first_name":"Test","last_name":"Lead"}}' \
"https://api.surecontact.com/api/v1/public/contacts/upsert"
# Get real list/tag UUIDs, then attach:
curl -s -H "X-API-Key: $KEY" "https://api.surecontact.com/api/v1/public/lists"
curl -s -H "X-API-Key: $KEY" "https://api.surecontact.com/api/v1/public/tags"
# Clean up the test contact afterward:
curl -s -X DELETE -H "X-API-Key: $KEY" \
"https://api.surecontact.com/api/v1/public/contacts/{uuid}"
SURECONTACT_API_KEY predating the public API is invalid — regenerate in the dashboard.await the capture in a way that can fail the user's request.(For the BMBB project specifically; other clients differ.) All UUIDs below verified against the live GET /lists and GET /tags on 2026-07-23 — SureContact-native and current.
6cd7d09d-1559-42f1-a85a-4f094bedeac4 → SURECONTACT_LEADS_LIST_UUID1782ca49-7c1e-4ab9-97b0-a93bbd346042 → SURECONTACT_NEW_LEAD_TAG_UUIDdfeb8eed-d88a-4ada-9670-9de8827ffb89, Past Clients b7136a3b-2078-4201-91da-8c903e7b1abf2747cf3f-32ce-404e-8a75-f049fd550138, Active Client 69af2551-bdfa-4b59-9075-d066c3d634f1, Past Due b81dac77-8c14-4560-9e60-423485dee591, Case Closed 13e53e57-9a80-4938-bdde-97dff8738d5dBMBB env to set (Coolify dashboard for prod):
SURECONTACT_API_KEY=<the public API key generated 2026-07-23>
SURECONTACT_LEADS_LIST_UUID=6cd7d09d-1559-42f1-a85a-4f094bedeac4
SURECONTACT_NEW_LEAD_TAG_UUID=1782ca49-7c1e-4ab9-97b0-a93bbd346042