A contact is the master identity record in CRM — email, phone, address, plus arbitrary properties and tags. Every other CRM entity (leads, opportunities, conversations, tickets) eventually references a contact. Contacts can be created publicly (from a form), via import, by sign-up, or by direct CRUD against the data API.
Public form submission
The CRM controller exposes two @PublicRoute() endpoints designed for unauthenticated marketing pages.
Map a form to a record
/crm/contact-form/post/:app/:nameNo auth:app is the app namespace (often the org's site name); :name is the form name registered in the admin. The body uses URL-encoded form fields. AppEngine maps the fields by name onto a contact record using the form's configured field mapping.
<form action="https://appengine.appmint.io/crm/contact-form/post/marketing/contact-us"
method="POST" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="orgid" value="my-org" />
<input name="email" type="email" required />
<input name="name" />
<textarea name="message"></textarea>
<button type="submit">Send</button>
</form>
The response is a redirect (302) to the form's configured successUrl, with the new contact's SK appended as ?ref=<sk>.
JSON variant
/crm/contact-form/json/:app/:nameNo authSame idea, but accepts and returns JSON — what you want from a Next.js form handler:
await fetch('/api/crm/contact-form/json/marketing/contact-us', {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID },
body: JSON.stringify({
email: '[email protected]',
name: 'Alice',
message: 'Interested in pricing',
source: 'pricing-page',
}),
});
Get the form definition
/crm/form/:nameNo auth/crm/form/submit/:name/:email?No authGET /crm/form/:name returns the form's field definitions — useful when you want to render a form dynamically from the CMS rather than hard-coding the HTML. POST /crm/form/submit/:name validates and submits.
Public form endpoints are rate-limited per IP and per email. Add a hCaptcha or Turnstile token via the admin form config — AppEngine validates it server-side before creating the record.
Contact CRUD
The contact collection is the master "contact" datatype. Use the generic data endpoints:
/repository/create/contactJWT/repository/get/contact/:idJWT/repository/get/contact/:idJWT/repository/get/contact/:idJWT/repository/find/contactJWT// Create
const created = await fetch('/api/data/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
body: JSON.stringify({
data: {
email: '[email protected]',
name: 'Alice Doe',
phone: '+15551234567',
tags: ['newsletter', 'pricing-page'],
properties: { plan_interest: 'pro', company: 'Acme' },
},
}),
}).then(r => r.json());
// Find by attribute
const found = await fetch('/api/repository/find-by-attribute/contact/email/[email protected]', {
headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
}).then(r => r.json());
The contact data shape:
{
email: string; // unique within org
name?: string;
firstName?: string;
lastName?: string;
phone?: string;
addresses?: Address[];
tags?: string[];
properties?: Record<string, any>; // free-form custom fields
source?: string; // where they came from (form name, ad campaign, etc.)
consent?: { marketing: boolean; sms: boolean; updatedAt: string };
customerGroups?: string[];
ownerId?: string; // assigned user SK
}
Tags
Tags are simple string arrays on the contact. Add or remove via the data API or via the dedicated tag endpoints (also dispatch automation events tag.added / tag.removed):
// Add a tag — fires `contact.tagged`
await fetch(`/api/data/contact/${contactSk}/tag/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
body: JSON.stringify({ tags: ['high-value'] }),
});
Tag-driven flows (e.g. "send onboarding email when tag signed-up is added") live entirely in the Automation module — no extra wiring on the contact side.
Properties
Properties are the custom-field bucket. Define them once in the admin (with type, label, validation) and AppEngine renders them in form builders and segment editors. The contact stores them as plain data.properties.<key>.
// Update a single property
await fetch(`/api/data/contact/${contactSk}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
body: JSON.stringify({ data: { properties: { plan_interest: 'enterprise' } } }),
});
PATCH does a deep-merge on data — only the keys you send change.
Segments
A segment is a saved query. Segments are stored under the audience controller:
/crm/marketing/audiences/segmentsJWT/crm/marketing/audiences/segmentsJWT/crm/marketing/audiences/segments/:idJWT/crm/marketing/audiences/segments/:idJWT/crm/marketing/audiences/segments/:idJWTThe segment body is a Dynamic Query — the same query structure used everywhere in AppEngine:
{
"name": "High-value newsletter subscribers",
"criteria": {
"and": [
{ "field": "tags", "op": "contains", "value": "newsletter" },
{ "field": "properties.lifetime_value", "op": ">=", "value": 1000 }
]
}
}
Segments are evaluated lazily — the membership query runs whenever a campaign sends, when an audience preview is requested, or when an automation flow asks "is this contact in segment X". They're not materialized lists; the count moves as your contact data moves.
Estimate reach
/crm/marketing/audiences/estimate-reachJWT/crm/marketing/audiences/segments/:id/insightsJWT/crm/marketing/audiences/segments/:id1/overlap/:id2JWTestimate-reach accepts a draft segment criteria and returns the count + breakdown by source / channel. insights returns the same for an existing segment plus engagement metrics. overlap shows how many contacts are in both segments.
What writes fire
Every contact write fires events into the automation bus:
contact.created— new recordcontact.updated— any field changecontact.tagged/contact.untagged— withtagpayloadcontact.consent_changed— whendata.consentflips
Use these as triggers in Automation flows; nothing else is required to wire them up.