API

A contact form API that proves delivery.

Post your form to one endpoint and get a receipt that says the message is stored. Heartdove then sends your alert and records whether it was delivered or bounced. Plain HTML forms, fetch and the typed client all speak the same contract.

Updated 20 October 2026

The endpoint

Send a POST to https://submit.heartdove.com/v1/submit as a regular form post or as JSON. The request must come from a website address registered on the form: the browser sends that Origin header automatically, and a server-side call has to set it. The endpoint answers CORS preflights, so fetch from your page works without a proxy.

access_keyThe form’s public key. Required. It identifies the form and grants no access.
fieldsJSON: an object of up to 50 string, number or boolean fields. Form posts send each field directly.
Idempotency-KeyHeader, 8 to 128 characters. Reuse it on a retry so the message is never stored twice.
botcheckA hidden field people never see. Any value quarantines the message as spam.
_redirectOptional, form posts only. A page on a registered website to send the visitor to afterwards.
turnstile_tokenOnly for forms that use Cloudflare Turnstile. Single use; get a fresh token on every retry.

From an HTML form

The simplest client is the browser itself. The visitor gets a confirmation page, or your own page if the form sets _redirect.

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>

From JavaScript

Post JSON to stay on the page. Generate the idempotency key once per message and reuse it for retries.

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 } }

Try it with curl

Set the Origin header to one of the form’s registered addresses; without it the request is refused with origin_not_allowed.

Shell
curl -X POST https://submit.heartdove.com/v1/submit \
  -H "Content-Type: application/json" \
  -H "Origin: https://www.yourdomain.com" \
  -H "Idempotency-Key: 6c1f2d0e-8e1b-4d7e-9a0b-3f2c1d4e5f60" \
  -d '{"access_key":"YOUR_ACCESS_KEY","fields":{"email":"alex@example.com","message":"Hello from curl"}}'

The receipt

A 202 means the submission is stored. It is a capture receipt, not proof that the email arrived; delivery appears on the message timeline in your inbox. A retry with the same idempotency key returns 200 with duplicate: true and the original ID.

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 automatically when your allowance resets; never billed as overage.
quarantinedStored for your review because it looked like spam.

Errors and retries

Every error returns { success: false, error: { code, message, retryable } }. Retry only when retryable is true, with the same idempotency key. The full list, including Turnstile and redirect codes, is in the docs.

StatusCodeWhat to do
400invalid_bodySend JSON or form-encoded UTF-8 fields.
401invalid_keyThe access key is unknown or revoked.
403origin_not_allowedThe Origin header is missing or 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.
422invalid_fieldsUp to 50 fields of at most 10,000 characters each.
429rate_limitedToo many requests from one address. Wait for Retry-After seconds.
503temporarily_unavailableRetry with the same idempotency key.

After the receipt: delivery you can see

The API’s job ends at the receipt, but the message’s does not. Each submission in your inbox shows its own timeline: captured, accepted by the email provider, then delivered or bounced with the reason. A bounced address is paused and flagged. Signed webhooks carry the same submission to your systems with a timestamped HMAC signature and a stable event ID; see verifying webhooks.

The typed client

@heartdove/forms wraps the request, validates every response, keeps the idempotency key across retries and asks for a fresh Turnstile token on each attempt when a form uses one. Adapters for React, Vue and Svelte share one controller. The package ships with the Heartdove source and is not yet published to npm.

Questions

Is there a free contact form API?

Yes. The Free plan includes 500 submissions a month, an instant email alert for each, 90 days of history, delivery tracking and spam quarantine. No card is needed.

Is the access key secret?

No. It is published in your page. It identifies the form only; the origin check, rate limits and spam scoring do the protecting, and the key cannot read anything back.

Can I call the API from a server or a mobile app?

The endpoint is built for browsers: every post must carry an Origin header that matches a website address registered on the form. A server can set that header explicitly, as the curl example shows.

What are the limits on a request?

Up to 50 fields, 10,000 characters per field and a 64 KiB body. Text only; uploads are not accepted.

How fast is the endpoint?

Forms post to a Cloudflare Worker close to the visitor, and the receipt is returned as soon as the message is stored. Email and webhook deliveries are queued and retried separately, so a slow provider never slows the form.

Never lose another enquiry.

Connect your first form in a few minutes. Your existing design stays exactly as it is.