# Contact forms

> Send your website's own contact form to the inbox and answer every message by e-mail.

Updated 2026-10-27

Your website keeps its own form, with its own look. You only change where it sends: to a Luxivo
address like `https://luxivo.app/f/fk_…`. Every message lands in the inbox as a conversation, your
team is notified, and you answer by e-mail from the inbox. When the customer replies, the answer
comes back into the same conversation.

Create a form in **Contact forms**, pick the website it belongs to, and copy the code from its
**Install** section. A form accepts messages from the same websites as that website's widget
(**Websites** under **Brands → your brand → Name and look**). The Free plan includes one form, Starter
three, Pro ten and Business any number.

## Plain HTML

Point the form's `action` at your form's address. After sending, the browser comes back to the page
in `_redirect`.

```html
<form action="https://luxivo.app/f/fk_your_key" method="post">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <input type="hidden" name="_redirect" value="https://example.com/contact?sent=1">
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" hidden>
  <button>Send</button>
</form>
```

Without `_redirect`, the browser lands on a short thank-you page in your widget's language. You can
also set a default page under the form's **Settings**. Redirects only go to your allowed websites.

## JavaScript

Send the form with `fetch` and show the result on the page. Ask for JSON with the `Accept` header:

```js
const res = await fetch(form.action, {
  method: "POST",
  body: new FormData(form),
  headers: { Accept: "application/json" },
});
const { ok, error } = await res.json();
```

`ok` is `true` when the message was saved. Otherwise `error` says what's wrong in one sentence, and
`errors` lists every problem. JSON bodies (`Content-Type: application/json`) work too.

## Fields

Use any field names. Luxivo recognizes the common ones and fills the conversation from them:

| What | Names |
| --- | --- |
| E-mail | `email`, `e-mail`, `mail` |
| Name | `name`, `full_name`, or `first_name` and `last_name` |
| Phone | `phone`, `tel`, `telephone` |
| Message | `message`, `msg`, `comments` |
| Subject | `subject`, `_subject`, `topic` |

Polish names (`imie`, `nazwisko`, `telefon`, `wiadomosc`, `temat`) work as well. Every other field is
kept too, like a hidden `listing_id` or a select. A message needs an e-mail address or a phone
number. Checkboxes that are ticked show as "Yes", and fields with the same name are joined.

Under the form's **Fields** you can give fields labels, put them in order and make them required.
Give a checkbox a consent text, and the text is saved with every message where the box is ticked.

## Page and campaign

Two hidden fields tell you where the message came from. Fill them when the page loads:

```html
<input type="hidden" name="_page">
<input type="hidden" name="_referrer">
<script>
  document.querySelector('[name="_page"]').value = location.href;
  document.querySelector('[name="_referrer"]').value = document.referrer;
</script>
```

The inbox then shows the page and its `utm_` campaign tags next to the message.

## Spam

The hidden `_gotcha` field is a trap for bots: people never see it, so a message with it filled in is
dropped without an error. If your form already has a trap field, enter its name under **Settings**.
Each visitor can send up to 10 messages a minute, and only from your allowed websites.

## Newsletter signup

Under **Settings → Newsletter signup**, pick a newsletter list. Senders who tick a checkbox named
`newsletter` join it, with the list's e-mail confirmation if it has one. Describe the checkbox in
**Fields** with its consent text, and that text is stored as what they agreed to. See
[Newsletter](/docs/newsletter).

```html
<label><input type="checkbox" name="newsletter"> Send me the newsletter</label>
```

## Notifications and the automatic reply

Your team is notified like for any new conversation. Under **Notifications** you can also:

- send every message to addresses outside the team, such as an office mailbox,
- send messages to more people depending on a field, for example `agent` equals `anna` goes to Anna,
- answer the customer right away with an automatic reply. Use `{{name}}`, `{{email}}`, `{{message}}`
  or any field as `{{field_name}}` in it.

Replies and automatic replies go out with your website's sender name, logo, color and footer. Set
them under **E-mail → Look**, where you can preview them and send yourself a test.

## Replying

Form conversations are answered by e-mail. The reply box shows who it goes to and the subject. Enter
starts a new line, and Cmd+Enter (Ctrl+Enter on Windows) sends. Each reply carries your name and
company, the customer's message quoted below and any files you attach. The **AI** button writes a
draft, or improves, shortens, changes the tone of or translates what you wrote.

## Export and webhooks

**Messages** lists everything sent with the form and exports it to CSV.

On Pro and Business, a webhook posts every message as JSON to your URL, for Zapier, Make or a CRM:

```json
{
  "id": "…",
  "form": { "id": "…", "name": "Contact" },
  "createdAt": "2026-10-27T16:43:00.000Z",
  "email": "anna@example.com",
  "name": "Anna Nowak",
  "data": { "email": "anna@example.com", "message": "Is the flat still available?" },
  "fields": [{ "name": "email", "label": "E-mail", "value": "anna@example.com" }],
  "page": { "url": "https://example.com/contact", "utm": { "utm_source": "google" } },
  "conversationUrl": "https://luxivo.app/app/inbox?c=…"
}
```

Each request has an `X-Luxivo-Signature: t=<unix seconds>,v1=<hex>` header. `v1` is the
HMAC-SHA256 of `<t>.<raw body>` with the form's webhook secret. Check it, and reject old timestamps:

```js
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
```

Answer with any 2xx status. Other answers are tried again five times over about an hour, and the
form's **Messages** show the last status. **Send a test** posts a sample message.
