Forms, tours & analytics
Render a form from its definition, create leads, book tours with the property's provider, and report what visitors do.
Every endpoint here is called from your server. The pattern is always the same: the browser posts to a route handler on your site; the handler adds the website token and forwards to Resi.
Forms
GET /forms · GET /forms/{form} — enabled forms attached to the website's properties. Tag: forms:{property_id}.
| Parameter | |
|---|---|
property | A property slug |
key | Form keys, comma-separated (for example contact) |
type | contact or schedule_tour |
sort | name |
page, per_page | Default 50, ceiling 100 |
{
"id": "01000000-0000-4000-8000-00000000000e",
"key": "contact",
"name": "Contact Us",
"type": "contact",
"fields": [
{ "key": "full_name", "type": "text", "label": "Full Name", "placeholder": null, "required": false, "width": "full", "options": [], "settings": {} },
{ "key": "email", "type": "email", "label": "Email", "placeholder": null, "required": true, "width": "full", "options": [], "settings": {} },
{ "key": "move_in_date", "type": "move_in_date", "label": "Move In Date", "placeholder": null, "required": false, "width": "half", "options": [], "settings": { "minimum_date": "2026-02-01" } }
],
"tour_types": [],
"settings": {
"disclaimer": "We will never share your details.",
"redirect_url": "https://contract-property.test/thanks",
"success_message": "Thanks — we will be in touch.",
"submit_button_label": "Send"
},
"properties": [{ "id": "0100…0002", "slug": "contract-property", "name": "Contract Property" }],
"submit_path": "/api/sites/v1/forms/01000000-0000-4000-8000-00000000000e/submissions"
}Render the form from fields: a field's type is one of name, email, phone, message, move_in_date, text or select (with options); settings carries anything else the field defines, such as a minimum date. The same reading of the form validates the submission, so a form built from fields is always accepted.
tour_types[] lists the tour types a schedule_tour form offers, each { type, data: { enabled, title, description } }, for labelling the choice. properties[] names the properties the form serves; when there is more than one, a submission must say which.
A form by id that is disabled, malformed, or attached to none of this website's properties is a 404.
Submitting a form
POST /forms/{form}/submissions — creates a lead. Write limit: 60 / minute per website.
curl -X POST "$RESI_DELIVERY_API_URL/forms/01000000-0000-4000-8000-00000000000e/submissions" \
-H "Authorization: Bearer $RESI_DELIVERY_TOKEN" \
-H "X-Resi-Website: $RESI_WEBSITE_ID" \
-H "Idempotency-Key: 6f1c2b9e-3a1d-4c55-9a53-0d6f2f0e7a11" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{
"property": "contract-property",
"fields": { "full_name": "Alex Nguyen", "email": "renter@example.com" },
"context": {
"page_url": "https://www.example-apartments.com/contact",
"utm_source": "google",
"gclid": "abc123",
"resi_source_key": "GOA",
"resi_source_name": "google_paid",
"unit": "101"
},
"honeypot": ""
}'| Body key | |
|---|---|
fields | Required. Values keyed by each field's key. email is always required. A key the form does not define is a 422 |
property | A property slug. Required only when the form serves more than one of this website's properties |
context | Attribution and page context; string values. Allowed keys below; any other key is a 422 |
honeypot | The value of your hidden trap input |
Allowed context keys: page_url, landing_url, referrer, user_agent, ip_address, utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, gbraid, wbraid, msclkid, fbclid, ttclid, resi_source_key, resi_source_name, resi_visitor_id, resi_session_id, unit, floor_plan (the last two are slugs).
Because the request comes from your server, Resi cannot see the visitor. Forward their user_agent and ip_address in context.
{
"data": { "lead_id": "01a0b9f5-b6e9-738b-b3ad-881127ed77cd", "message": "Thanks — we will be in touch.", "redirect_url": "https://contract-property.test/thanks" },
"meta": { "duplicate": false, "discarded": false }
}| Status | Meaning |
|---|---|
201 | Lead created — or discarded by the honeypot |
200 | This Idempotency-Key was already processed; the original lead is returned with meta.duplicate: true |
409 | A submission with this Idempotency-Key is still being processed |
422 | Validation failed; errors is keyed by parameter, for example fields.email |
Show the visitor data.message, and follow data.redirect_url when it is set. Both are the client's own configuration.
Honeypot. Add a hidden input a person will never fill, and pass its value as honeypot. Anything non-empty discards the submission while answering exactly as a success (lead_id: null, meta.discarded: true). Show the visitor the same success; never reveal the difference.
What happens after. The lead is created and recorded as a demand event before the response. Email notifications and the form's CRM connections run on Resi's queue, retried on failure, so a slow CRM never holds the form open.
Tours
Tours are booked with the property's own tour provider, through a tour integration. Read it from GET /integrations?category=tour: its id is the connection_id, and tour_types[] names each type and its fields.
Availability
POST /tours/availability
{ "property": "contract-property", "connection_id": "01000000-0000-4000-8000-000000000020", "tour_type": "in_person_tour" }Returns the provider's open slots, normalised:
{
"data": {
"availability": [
{ "date": "2026-02-03", "timezone": "America/Chicago", "slots": [{ "start": "10:00", "end": "10:30" }, { "start": "10:30", "end": "11:00" }] }
]
},
"meta": {}
}Each day carries its IANA timezone; slot times are HH:MM in that timezone, and a slot's start is what tour_time expects. This is a live call to the provider: do not cache it for long, and expect 502 when the provider fails (safe to retry).
Reservation
POST /tours/reservations — books the slot with the provider and creates the lead.
{
"property": "contract-property",
"connection_id": "01000000-0000-4000-8000-000000000020",
"tour_type": "in_person_tour",
"tour_date": "2026-02-03",
"tour_time": "10:00",
"fields": { "first_name": "Alex", "last_name": "Nguyen", "email": "renter@example.com", "phone": "7655550134" },
"context": { "page_url": "https://www.example-apartments.com/tour", "resi_source_key": "GOA" }
}tour_date is YYYY-MM-DD; tour_time is HH:MM, 24 hour. fields is validated against the tour type's own schema; a key it does not define is a 422. context takes the same keys as a form submission.
{ "data": { "lead_id": "01a0b9f5-…", "message": "Tour reserved successfully." }, "meta": { "duplicate": false } }| Status | Meaning |
|---|---|
201 | Booked |
200 | This Idempotency-Key was already processed; the original booking is returned, nothing new is booked |
404 | A property this website does not have |
409 | The slot was taken, or the same Idempotency-Key is still being processed |
422 | Validation failed, or the connection is not an enabled tour connection of this property, or does not offer this tour type |
502 | The tour provider failed. Safe to retry with the same Idempotency-Key |
Always send an Idempotency-Key when booking. A retry after a timeout then returns the original booking instead of booking the slot twice with the provider.
Analytics events
POST /events — a demand event a page observed, forwarded by your server. Uses the read limit (1,200 / minute), since events arrive in volume.
{ "event_type": "unit_viewed", "property": "contract-property", "unit": "101", "session_id": "s_1", "anonymous_id": "a_1" }{ "data": { "id": "01a0b9f5-b6e9-738b-b3ad-881127ed77cd", "event_type": "unit_viewed", "status": "recorded" }, "meta": {} }event_type and property are required. Accepted types are the events a page can observe: unit_viewed, floor_plan_viewed, lease_term_selected, move_in_date_changed, price_matrix_loaded, fee_breakdown_viewed, fee_calculator_opened, apply_clicked, tour_modal_opened, form_started. Leads and tour reservations are recorded by the endpoints that create them; do not send them again.
Optional keys: occurred_at, unit, floor_plan (slugs on this property), form_id, session_id, anonymous_id, move_in_date, lease_term_months (1–36), displayed_base_rent, displayed_tmlp, displayed_fee_total (whole numbers — what the visitor was actually shown), destination_url, the source keys (lead_source_name, resi_source_name, resi_source_key, resi_external_source_field, resi_source_query, resi_source_timestamp, resi_source_method) and a free-form metadata map. Any other key is a 422.
Source observations
POST /source-observations — what the site saw about where a visit came from. Resi resolves it against the property's attribution rules and answers which source matched.
{
"property": "contract-property",
"landing_url": "https://www.example-apartments.com/?utm_source=google&gclid=abc",
"referrer": "https://www.google.com/",
"query_params": { "utm_source": "google" },
"click_ids": { "gclid": "abc" },
"session_id": "s_1"
}{ "data": { "id": "01a0b9f5-b6ef-70aa-9336-5d6484b49014", "status": "matched", "lead_source_id": 1, "resi_source_key": "GOA", "resi_source_name": "google_paid" }, "meta": {} }Only property is required. Other keys: occurred_at, anonymous_id, current_url, path, external_source_field, external_source_value, resi_source_key, resi_source_name, match_status, metadata.
What Resi stores is deliberately narrow:
- URLs are stored with credentials and fragments dropped, and only source-identifying query parameters kept.
- From
query_params: only UTM parameters, click ids, the CRM's source field and parameters the property's rules read. - From
click_ids: onlygclid,gbraid,wbraid,msclkid,fbclid,ttclid, each capped at 512 characters. metadatakeys that look personal or credential-shaped are stored as[redacted].
Use the returned resi_source_key and resi_source_name as the context of every later lead from that session.
Last updated on