All docs

@backendfree/inbox

Sending a contact form's message to the website's inbox, past the spam checks, and reading them back.

Updated 22 October 2026 About 3 minutes View as Markdown

A website's contact form, sent to its inbox on the dashboard, and the messages read back. Built on @backendfree/core.

sh
npm install @backendfree/core @backendfree/inbox
ts
import { Client } from '@backendfree/core';
import { Inbox, formTimer } from '@backendfree/inbox';

const inbox = new Inbox(
  new Client({ origin: 'https://backendfree.com', key: 'pk_live_...' }),
);

const opened = formTimer();                    // when the form appears

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  await inbox.send({
    name: String(data.get('name')),
    email: String(data.get('email')),
    message: String(data.get('message')),
    page: location.pathname,
    honeypot: String(data.get('website') ?? ''),
    filled_in_seconds: opened(),
  });
  showThanks();
});

The owner is emailed each message, with your visitor's address as Reply-To, and finds them all under Inbox on the dashboard. Switch Inbox on for the website first; until then /v1/messages answers 404.

Keeping spam out

Every message passes these checks, on the platform's side, before anybody sees it:

  • A hidden field. Add an input people never see and send whatever it holds as honeypot. A script filling every field fills it too, and that message is answered as received and not kept.
  • How fast it was sent. formTimer() measures how long the form was open; a form sent faster than a person can fill one in goes to spam.
  • Links. More than a couple of web addresses, or a link written as code, goes to spam.
  • Limits. One visitor address may send a few messages an hour to one website, and a website takes a set number a day. The limits page has the figures.

The hidden field, hidden from people and from screen readers but not from a script reading the page:

html
<div aria-hidden="true" style="position: absolute; left: -10000px">
  <label>Leave this empty <input name="website" tabindex="-1" autocomplete="off"></label>
</div>

Spam is kept for a while in the Spam folder, where the owner can move a message back. It is never emailed and never sent to a webhook.

send resolves the same whatever became of the message, so a script learns nothing from the answer. Two refusals throw, because a person can act on them:

code means
bot_check_failed The website asks for Cloudflare's check and the token was missing, spent or refused. Show the check again.
daily_limit_reached The website has had its messages for today. Suggest another way to get in touch.

Cloudflare's bot check

Optional, for a form a script keeps getting past. Make a Turnstile widget in your own Cloudflare account, put its site key in your form, and paste its secret key on the inbox's settings screen. From then on every message has to carry a token Cloudflare accepts:

ts
await inbox.send({
  name, email, message,
  bot_check: String(data.get('cf-turnstile-response') ?? ''),
});

A token works once, so after a refusal reset the widget before sending again. Cloudflare's test keys, which always pass or always fail, try it without a real widget.

From your own server

send works with a secret key as well, from a server action or an API route, for a form that posts to your own server first. The per-visitor limits then see your server rather than the visitor, so apply your own; the daily limit still holds.

Reading messages

Reading needs a secret key, and list and message throw on a publishable one before any request is made: a message is a stranger's name, address and words.

ts
import { verifyWebhook } from '@backendfree/core';

const event = await verifyWebhook<{ message_id: string }>({ body: await request.text(), headers, secret });
if (event.name === 'message.received') {
  const message = await inbox.message(event.data.message_id);
  await crm.add(message.email, message.name, message.message);
}

message.received carries the message's id and nothing anybody wrote in it. It is sent when a message arrives in the inbox, or is moved there out of spam.

ts
const { results } = await inbox.list({ folder: 'inbox' });