Documentation
Connect a form
Everything you need to send submissions to Heartdove and receive them safely in your own systems.
Quickstart
Create a form from your workspace to get its public access key, then paste the snippet into any page on the website address you registered. The form is live straight away.
<form action="https://submit.heartdove.com/v1/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<label>Email <input type="email" name="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<!-- Hidden spam trap: people never see it, bots tick it -->
<input type="checkbox" name="botcheck" tabindex="-1" autocomplete="off" style="display:none" />
<button type="submit">Send message</button>
</form>Request format
Send a POST to https://submit.heartdove.com/v1/submit as JSON or as a regular form post.
| access_key | Your form’s public key. Required. |
|---|---|
| turnstile_token | Only for forms that use Cloudflare Turnstile. The widget token (form posts send it as cf-turnstile-response). Single use. |
| fields | An object of up to 50 string, number or boolean fields. Form posts send each field directly. A checkbox group or multi-select may repeat one name (extras or extras[]), and a JSON field may be a list; the values are stored together as “Hosting, SEO”. A field named subject becomes the message’s subject line. |
| Idempotency-Key | Header, 8 to 128 characters. Reuse it when retrying so a message is never stored twice. |
| honeypot / botcheck | A hidden field people never see. Any value marks the message as spam, so it is quarantined for review instead of alerting you. The generated snippet includes it. |
| _redirect | Optional. A page on one of the form’s registered websites to send the visitor to after a plain HTML post, for example https://example.com/thanks. JSON requests get the receipt instead. |
const response = await fetch('https://submit.heartdove.com/v1/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(), // reuse it when you retry
},
body: JSON.stringify({
access_key: 'YOUR_ACCESS_KEY',
// turnstile_token: token, // only if the form uses Cloudflare Turnstile
fields: { email, message },
}),
});
const receipt = await response.json();
// 202 { success: true, data: { id, status: 'accepted', duplicate: false } }Receipts
A 202 means the submission is stored. It is a capture receipt, not proof of email delivery; delivery appears on the message timeline in your inbox. A retry with the same key returns 200 with duplicate: true.
HTTP/1.1 202 Accepted
{
"success": true,
"data": {
"id": "83841cff-c4f9-4901-9167-8e9fd559c990",
"status": "accepted",
"duplicate": false
}
}| accepted | Stored and queued for delivery. |
|---|---|
| held_overage | Stored and held because the plan limit was reached. Released when capacity allows; the hold lasts at least seven days and until one day after the next monthly allowance reset. |
| quarantined | Stored for review because it looked like spam. |
Browsers posting a plain HTML form receive a simple confirmation page instead of JSON, or are sent to your own thank-you page when the form includes _redirect.
Errors and retries
Every error returns { success: false, error: { code, message, retryable } }. Retry only when retryable is true, with the same idempotency key, and a fresh Turnstile token if the form uses one.
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_body, invalid_encoding | Send JSON or form-encoded UTF-8 fields. |
| 401 | invalid_key | The access key is unknown or revoked. |
| 403 | origin_not_allowed | The page origin is not registered for this form. |
| 409 | idempotency_conflict | The idempotency key was reused with different fields. Use a new key. |
| 413 | body_too_large | Keep the body under 64 KiB. |
| 415 | unsupported_content_type | Use application/json or a regular form post. Uploads are not accepted. |
| 422 | invalid_fields, field_too_long | Up to 50 fields of at most 10,000 characters each. |
| 422 | verification_required, verification_failed | Only on forms that use Turnstile: the token is missing, failed or expired. Get a new token and retry. |
| 422 | invalid_redirect, redirect_not_allowed | The _redirect value is not a full http(s) URL, or its origin is not registered for this form. |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds. |
| 503 | temporarily_unavailable | Retry with the same idempotency key (and a new Turnstile token if the form uses one). |
| 507 | record_capacity_exhausted and similar | The workspace is out of storage or holding capacity. |
JavaScript SDK
The typed client wraps the request, validates every response, keeps the idempotency key across retries and, for forms that use Turnstile, asks for a fresh token on each attempt through getToken. client.begin(fields) returns an attempt you can retry after a network failure without creating a second submission.
@heartdove/forms package ships with the Heartdove source and is not yet published to npm.Framework adapters
The React hook, Vue directive and Svelte action share one controller, so behaviour and states are identical across frameworks. Each has been mounted in its real runtime and posted end to end, including through Turnstile.
import { createClient, readForm } from '@heartdove/forms';
import { useFormSubmit } from '@heartdove/forms/react';
const client = createClient({
endpoint: 'https://submit.heartdove.com/v1/submit',
accessKey: 'YOUR_ACCESS_KEY',
});
export function Contact() {
const { state, submit } = useFormSubmit(client);
return (
<form onSubmit={e => { e.preventDefault(); submit(readForm(e.currentTarget).fields); }}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={state.phase === 'submitting'}>Send message</button>
</form>
);
}Platforms and site builders
Every platform sends the same request: the HTML snippet or a fetch call. The form must be served from the origin you registered, because Heartdove checks it exactly. Plain HTML and the React, Vue and Svelte adapters have been tested in real browsers; the rows below describe where the same snippet goes on each platform.
| HTML, Bootstrap, Tailwind CSS, Alpine.js, Hugo | Paste the HTML snippet into your page or template. Your markup and classes stay exactly as they are. |
|---|---|
| JavaScript | Post JSON with fetch, or use the SDK for typed results and safe retries. |
| React, Next.js, Gatsby | Use the React hook. In the Next.js App Router, put the form in a client component. |
| Vue.js, Nuxt | Use the Vue directive. In Nuxt, render the form on the client. |
| Svelte, SvelteKit | Use the Svelte action. |
| Astro | Paste the HTML snippet into an .astro page, or hydrate a React, Vue or Svelte form with client:load. |
| Webflow, Squarespace, WordPress, Shopify | Add the HTML snippet with an Embed element, a Code block, a Custom HTML block or a Custom Liquid section. |
| Framer | Add the snippet with a code component or the site’s custom code. If the form renders inside an embed frame, register the origin that frame reports. |
Signed webhooks
Webhook destinations receive submission.created events. Each request carries:
| X-Heartdove-Id | A stable event ID. Delivery is at least once, so store it and ignore repeats. |
|---|---|
| X-Heartdove-Timestamp | Unix seconds. Reject anything older than five minutes. |
| X-Heartdove-Signature | v1= plus the hex HMAC-SHA256 of timestamp.rawBody with your shared secret. |
import { createHmac, timingSafeEqual } from 'node:crypto';
// Verify the raw body before parsing it. Reject anything older than five minutes.
export function verifyHeartdove(rawBody, headers, secret) {
const timestamp = headers['x-heartdove-timestamp'];
const signature = headers['x-heartdove-signature'] ?? '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// Deduplicate on the X-Heartdove-Id header: delivery is at least once.Redirects are never followed and a non-2xx response is retried with backoff. Destinations must use an operator-approved HTTPS hostname. Questions? Read how Heartdove protects data.