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.

HTML
<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>
The access key is public: it identifies the form, it does not grant access. Protection comes from exact origin checks, per-visitor rate limits, a hidden spam trap and spam scoring with quarantine, plus Cloudflare Turnstile on forms that use it.

Request format

Send a POST to https://submit.heartdove.com/v1/submit as JSON or as a regular form post.

access_keyYour form’s public key. Required.
turnstile_tokenOnly for forms that use Cloudflare Turnstile. The widget token (form posts send it as cf-turnstile-response). Single use.
fieldsAn 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-KeyHeader, 8 to 128 characters. Reuse it when retrying so a message is never stored twice.
honeypot / botcheckA 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.
_redirectOptional. 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.
JavaScript
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.

Response
HTTP/1.1 202 Accepted

{
  "success": true,
  "data": {
    "id": "83841cff-c4f9-4901-9167-8e9fd559c990",
    "status": "accepted",
    "duplicate": false
  }
}
acceptedStored and queued for delivery.
held_overageStored 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.
quarantinedStored 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.

StatusCodeWhat to do
400invalid_body, invalid_encodingSend JSON or form-encoded UTF-8 fields.
401invalid_keyThe access key is unknown or revoked.
403origin_not_allowedThe page origin is not registered for this form.
409idempotency_conflictThe idempotency key was reused with different fields. Use a new key.
413body_too_largeKeep the body under 64 KiB.
415unsupported_content_typeUse application/json or a regular form post. Uploads are not accepted.
422invalid_fields, field_too_longUp to 50 fields of at most 10,000 characters each.
422verification_required, verification_failedOnly on forms that use Turnstile: the token is missing, failed or expired. Get a new token and retry.
422invalid_redirect, redirect_not_allowedThe _redirect value is not a full http(s) URL, or its origin is not registered for this form.
429rate_limitedToo many requests. Wait for Retry-After seconds.
503temporarily_unavailableRetry with the same idempotency key (and a new Turnstile token if the form uses one).
507record_capacity_exhausted and similarThe 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.

The @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.

React
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, HugoPaste the HTML snippet into your page or template. Your markup and classes stay exactly as they are.
JavaScriptPost JSON with fetch, or use the SDK for typed results and safe retries.
React, Next.js, GatsbyUse the React hook. In the Next.js App Router, put the form in a client component.
Vue.js, NuxtUse the Vue directive. In Nuxt, render the form on the client.
Svelte, SvelteKitUse the Svelte action.
AstroPaste the HTML snippet into an .astro page, or hydrate a React, Vue or Svelte form with client:load.
Webflow, Squarespace, WordPress, ShopifyAdd the HTML snippet with an Embed element, a Code block, a Custom HTML block or a Custom Liquid section.
FramerAdd 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-IdA stable event ID. Delivery is at least once, so store it and ignore repeats.
X-Heartdove-TimestampUnix seconds. Reject anything older than five minutes.
X-Heartdove-Signaturev1= plus the hex HMAC-SHA256 of timestamp.rawBody with your shared secret.
Node.js
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.