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:
| Field | Used for |
|---|---|
handle | Your public booking URL. Generated automatically — you never pick it at signup. |
session.accessToken | Bearer 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:
| Deployment | API base URL |
|---|---|
| Local development | http://localhost:8080 |
| Render / Fly / your host | https://<your-service>.onrender.com |
Confirm it is reachable before going further:
curl https://your-api-host/readyz
# {"status":"ok","checks":{"postgres":"ok"}}
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 type | Renders as |
|---|---|
FIELD_TYPE_TEXT | single-line input |
FIELD_TYPE_EMAIL | email input |
FIELD_TYPE_TEXTAREA | multi-line box |
FIELD_TYPE_SELECT | dropdown — supply options |
FIELD_TYPE_NUMBER | numeric 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 name | Becomes |
|---|---|
name | senderName |
email | senderEmail |
_hl_hp | the honeypot — excluded from your data |
| anything else | a 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?';
});
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.
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">
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 } ]
10Share your booking link
Your booking page is served by the HaloMail web app at your
handle from step 1:
https://your-web-host/book/your-name-c126af
Link it from anywhere on your portfolio:
<a href="https://your-web-host/book/your-name-c126af">Book a call</a>
Or embed it inline, so visitors never leave your site:
<iframe src="https://your-web-host/book/your-name-c126af"
width="100%" height="720" style="border:0"
title="Book a meeting"></iframe>
To look up a handle's public profile — for a name or timezone on your own page:
curl -X POST https://your-api-host/halomail.identity.v1.UserService/GetUserByHandle \
-H "Content-Type: application/json" \
-d '{ "handle": "your-name-c126af" }'
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
| Endpoint | Auth |
|---|---|
MessageService/SubmitMessage | public |
BookingService/ListSlots | public |
BookingService/CreateBooking | public |
UserService/GetUserByHandle | public |
FormService/* | bearer |
EventTypeService/*, AvailabilityService/* | bearer |
MessageService/ListMessages and other reads | bearer |
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" }
| Code | Usually means |
|---|---|
invalid_argument | Missing or malformed field — check required fields and JSON key names. |
not_found | Wrong formSlug or eventTypeId. |
unauthenticated | Missing, malformed, or expired bearer token. |
permission_denied | Valid token, but the resource belongs to someone else. |
resource_exhausted | Rate limited — back off and retry. |
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:
| Check | How |
|---|---|
| API is reachable from the browser | Open /readyz in a tab — expect postgres: ok |
| Form slug matches | data-halomail equals the slug you created |
| Honeypot present | A hidden _hl_hp input inside the form |
| Error path handled | A halomail:error listener, or a catch block |
| Submission actually arrives | Send a test message, then check the dashboard |
| Availability set | Booking page shows selectable slots, not an empty calendar |
| No token in front-end code | Search 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.