HaloMail docs
No framework required One script tag Works on static sites

Add HaloMail to your website

Put a working contact form and a public booking page on your portfolio in about ten minutes — with plain HTML, or with React if you prefer.

Every request and response on this page was executed against a live HaloMail deployment. The JSON shapes are real, not illustrative.

Before you begin

You need two things: a running HaloMail API, and a website to put the snippets on. The website can be anything that serves HTML — a static portfolio, Astro, Next.js, WordPress, a single index.html on GitHub Pages.

Contact form

Visitors send you a message. Submissions are stored and forwarded to your target email.

Booking page

Visitors pick a free slot from your availability and book a meeting.

Your own UI

Both are plain JSON over HTTP — build whatever interface you like on top.

HaloMail speaks ConnectRPC, which over HTTP is just POST with a JSON body. No special client is required — the browser's fetch is enough.

1Create an account

Use the dashboard at /register, or call the API directly:

curl -X POST https://your-api-host/halomail.identity.v1.AuthService/Register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "you@example.com",
    "password": "a-long-password",
    "name": "Your Name"
  }'

The response contains your user and a session:

{
  "user": {
    "id": "usr_01a01b8f-2062-73bf-b403-5918e62519a5",
    "email": "you@example.com",
    "name": "Your Name",
    "handle": "your-name-c126af",
    "timezone": "UTC",
    "orgId": "org_01a01b8f-2062-73a9-93e0-694807228284"
  },
  "session": { "accessToken": "eyJhbGciOi…", "refreshToken": "…" }
}

Two values matter later:

FieldUsed for
handleYour public booking URL. Generated automatically — you never pick it at signup.
session.accessTokenBearer token for every authenticated call below.

2Find your API URL

Everything on this page is relative to one host. Where it lives depends on how HaloMail was deployed:

DeploymentAPI base URL
Local developmenthttp://localhost:8080
Render / Fly / your hosthttps://<your-service>.onrender.com

Confirm it is reachable before going further:

curl https://your-api-host/readyz
# {"status":"ok","checks":{"postgres":"ok"}}
Sleeping instances. On free hosting tiers the API may sleep after inactivity, and the first request can take up to a minute while it wakes. Your visitors will notice this on a cold contact form — worth knowing before you blame your code.

3Create a form auth

A form defines where submissions go and which fields you expect. Create it once — from the dashboard, or with the token from step 1:

curl -X POST https://your-api-host/halomail.contact.v1.FormService/CreateForm \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "name": "Portfolio Contact",
    "slug": "portfolio",
    "targetEmail": "you@example.com",
    "spamProtection": "SPAM_PROTECTION_HONEYPOT",
    "fields": [
      { "name": "name",    "label": "Name",    "type": "FIELD_TYPE_TEXT",     "required": true },
      { "name": "email",   "label": "Email",   "type": "FIELD_TYPE_EMAIL",    "required": true },
      { "name": "message", "label": "Message", "type": "FIELD_TYPE_TEXTAREA", "required": true }
    ]
  }'

The slug is what your website references. Keep it short and stable — changing it later breaks every page embedding the form.

Field typeRenders as
FIELD_TYPE_TEXTsingle-line input
FIELD_TYPE_EMAILemail input
FIELD_TYPE_TEXTAREAmulti-line box
FIELD_TYPE_SELECTdropdown — supply options
FIELD_TYPE_NUMBERnumeric input

4Drop-in widget no auth

The fastest path. One script tag, one data-halomail attribute on a form you already styled. The widget intercepts the submit, posts the values, and resets the form.

<script src="https://your-api-host/widget.js" defer></script>

<form data-halomail="portfolio">
  <input name="name" placeholder="Your name" required>
  <input name="email" type="email" placeholder="you@example.com" required>
  <textarea name="message" placeholder="What's up?" required></textarea>

  <!-- honeypot: real people never fill this in -->
  <input name="_hl_hp" tabindex="-1" autocomplete="off" style="display:none">

  <button type="submit">Send</button>
</form>

That is the entire integration. No build step, no npm install, nothing to configure — the widget reads the API host from its own <script src>, so it always talks to the server that served it.

How your fields are mapped

Input nameBecomes
namesenderName
emailsenderEmail
_hl_hpthe honeypot — excluded from your data
anything elsea key inside data

Reacting to success and failure

The widget fires DOM events on the form element, so you control the UI:

const form = document.querySelector('form[data-halomail]');

form.addEventListener('halomail:sent', (e) => {
  status.textContent = 'Thanks — I\'ll reply soon.';
});

form.addEventListener('halomail:error', (e) => {
  status.textContent = 'Something went wrong. Email me directly?';
});
Always handle the error event. The form silently does nothing on failure otherwise, and a contact form that quietly swallows messages is worse than no contact form.

Calling it manually

The script also exposes a function, for custom markup or a modal:

HaloMail.contact('portfolio', {
  name: 'Jane Visitor',
  email: 'jane@example.com',
  data: { message: 'Saw your work — can we talk?' }
}).then(console.log);

5Without the widget no auth

If you'd rather not load third-party script, post the JSON yourself. This is the exact request the widget makes:

await fetch('https://your-api-host/halomail.contact.v1.MessageService/SubmitMessage', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    formSlug:    'portfolio',
    senderName:  'Jane Visitor',
    senderEmail: 'jane@example.com',
    data:        { message: 'Saw your portfolio, let\'s talk.' },
    honeypot:    ''
  })
});

Response:

{ "id": "msg_01a01b8f-569e-7a6e-a49e-477e778f8608", "accepted": true }

No API key, no token, no CORS setup — this endpoint is public by design, because it runs in your visitors' browsers.

Never put your access token in front-end code. A bearer token grants full control of your account. Public endpoints (no auth) are the only ones that belong in a browser; everything marked auth belongs on a server or in the dashboard.

6React / Next.js

Same request, wrapped in a component. Works in any React app — no HaloMail package to install.

'use client';
import { useState } from 'react';

const API = process.env.NEXT_PUBLIC_API_URL;

export function ContactForm() {
  const [state, setState] = useState('idle');

  async function onSubmit(e) {
    e.preventDefault();
    setState('sending');
    const f = new FormData(e.currentTarget);

    try {
      const res = await fetch(`${API}/halomail.contact.v1.MessageService/SubmitMessage`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          formSlug:    'portfolio',
          senderName:  f.get('name'),
          senderEmail: f.get('email'),
          data:        { message: f.get('message') },
          honeypot:    f.get('_hl_hp') ?? ''
        })
      });
      if (!res.ok) throw new Error(await res.text());
      setState('sent');
    } catch {
      setState('error');
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <input name="_hl_hp" tabIndex={-1} className="hidden" autoComplete="off" />
      <button disabled={state === 'sending'}>
        {state === 'sending' ? 'Sending…' : 'Send'}
      </button>
      {state === 'sent' && <p>Thanks — I'll reply soon.</p>}
      {state === 'error' && <p>Couldn't send. Try email instead.</p>}
    </form>
  );
}

Put the API host in an environment variable rather than hard-coding it, so local and production builds point at different servers:

# .env.local
NEXT_PUBLIC_API_URL=https://your-api-host

7Spam protection & redirects

Honeypot

With SPAM_PROTECTION_HONEYPOT, include a hidden field named _hl_hp. Humans never see it; bots fill everything. A submission arriving with that field non-empty is treated as spam.

<input name="_hl_hp" tabindex="-1" autocomplete="off"
       style="position:absolute;left:-9999px" aria-hidden="true">
Hide it with position:absolute;left:-9999px rather than display:none if you want to catch more bots — many skip fields that are display-none, but fill in ones that are merely off-screen.

Redirect after submit

Set redirectUrl on the form and the widget sends the visitor there on success — useful for a dedicated thank-you page:

curl -X POST https://your-api-host/halomail.contact.v1.FormService/UpdateForm \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "id": "frm_…", "redirectUrl": "https://yoursite.com/thanks" }'

Leave it empty to stay on the page and handle halomail:sent yourself — better for single-page sites.

8Create an event type auth

An event type is a kind of meeting people can book — its title, its length, its URL slug.

curl -X POST https://your-api-host/halomail.scheduling.v1.EventTypeService/CreateEventType \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "title": "Intro Call",
    "slug": "intro-call",
    "durationMinutes": 30,
    "description": "30 min chat"
  }'
{
  "eventType": {
    "id": "evt_01a01b8f-858b-7788-8bdb-4027cef65bd0",
    "slug": "intro-call",
    "durationMinutes": 30,
    "active": true
  }
}

Keep the id — the public slot and booking endpoints take eventTypeId, not the slug.

9Set your availability auth

Availability is weekly rules in your timezone. Weekday is 0 = Sunday through 6 = Saturday, and times are minutes from midnight — 9:00 is 540, 17:00 is 1020.

curl -X POST https://your-api-host/halomail.scheduling.v1.AvailabilityService/SetAvailability \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "timezone": "Asia/Kolkata",
    "rules": [
      { "weekday": 1, "startMinute": 540, "endMinute": 1020 },
      { "weekday": 2, "startMinute": 540, "endMinute": 1020 },
      { "weekday": 3, "startMinute": 540, "endMinute": 1020 },
      { "weekday": 4, "startMinute": 540, "endMinute": 1020 },
      { "weekday": 5, "startMinute": 540, "endMinute": 1020 }
    ]
  }'

Block or open specific dates with overrides — a holiday, or a one-off Saturday:

"overrides": [ { "date": "2026-12-25", "available": false } ]
Without availability, no slots exist. The booking page will render but show nothing bookable — the most common reason a fresh booking link looks broken.

11Build your own booking UI no auth

If the hosted page doesn't fit your design, both booking endpoints are public. Two calls: list slots, then create the booking.

List free slots

const res = await fetch(`${API}/halomail.scheduling.v1.BookingService/ListSlots`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    eventTypeId:     'evt_01a01b8f-858b-7788-8bdb-4027cef65bd0',
    fromDate:        '2026-08-24',
    toDate:          '2026-08-25',
    inviteeTimezone: 'Asia/Kolkata'
  })
});
{
  "slots": [
    { "start": "2026-08-24T03:30:00Z", "end": "2026-08-24T04:00:00Z" },
    { "start": "2026-08-24T04:00:00Z", "end": "2026-08-24T04:30:00Z" }
  ]
}

Slots come back in UTC. Render them in the visitor's local time — inviteeTimezone decides which working hours are offered, not the format of the response.

Create the booking

await fetch(`${API}/halomail.scheduling.v1.BookingService/CreateBooking`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    eventTypeId:     'evt_…',
    inviteeName:     'Jane Visitor',
    inviteeEmail:    'jane@example.com',
    inviteeTimezone: 'Asia/Kolkata',
    start:           '2026-08-24T03:30:00Z',
    notes:           'Portfolio enquiry'
  })
});

Pass start exactly as it came back from ListSlots — an arbitrary timestamp that doesn't match a real slot is rejected.

Authentication

Authenticated calls take a bearer token in the header:

Authorization: Bearer <session.accessToken>

Access tokens are short-lived (about 15 minutes). When one expires, mint a new one with the refresh token instead of asking the user to log in again:

curl -X POST https://your-api-host/halomail.identity.v1.AuthService/RefreshSession \
  -H "Content-Type: application/json" \
  -d '{ "refreshToken": "…" }'

Which endpoints need auth

EndpointAuth
MessageService/SubmitMessagepublic
BookingService/ListSlotspublic
BookingService/CreateBookingpublic
UserService/GetUserByHandlepublic
FormService/*bearer
EventTypeService/*, AvailabilityService/*bearer
MessageService/ListMessages and other readsbearer
API keys are not yet accepted as credentials. ApiKeyService/CreateApiKey issues a secret, and VerifyApiKey can check one, but no request middleware reads an API-key header today. Use bearer tokens for server-to-server calls until that lands.

Rate limits

Public endpoints are rate limited per deployment — by default 10 requests per second with a burst of 20, tunable via RATELIMIT_PUBLIC_RPS and RATELIMIT_PUBLIC_BURST.

Comfortably above what a portfolio contact form needs, and low enough to blunt a naive spam script. Over the limit, the API responds with resource_exhausted — treat it as retryable, and don't hammer.

Errors

Errors arrive as JSON with a Connect error code:

{ "code": "not_found", "message": "form not found" }
CodeUsually means
invalid_argumentMissing or malformed field — check required fields and JSON key names.
not_foundWrong formSlug or eventTypeId.
unauthenticatedMissing, malformed, or expired bearer token.
permission_deniedValid token, but the resource belongs to someone else.
resource_exhaustedRate limited — back off and retry.
JSON keys are camelCase over HTTP (formSlug, senderEmail, eventTypeId) even though the protobuf definitions use snake_case. Sending form_slug is the single most common cause of a mystifying invalid_argument.

Checklist

Before you call it done:

CheckHow
API is reachable from the browserOpen /readyz in a tab — expect postgres: ok
Form slug matchesdata-halomail equals the slug you created
Honeypot presentA hidden _hl_hp input inside the form
Error path handledA halomail:error listener, or a catch block
Submission actually arrivesSend a test message, then check the dashboard
Availability setBooking page shows selectable slots, not an empty calendar
No token in front-end codeSearch your bundle for Bearer — there should be no hits

Full API reference in the main docs · HaloMail is MIT-licensed and open to contributions — see CONTRIBUTING.md.