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_key | The form’s public key. Required. It identifies the form and grants no access. |
|---|---|
| fields | JSON: an object of up to 50 string, number or boolean fields. Form posts send each field directly. |
| Idempotency-Key | Header, 8 to 128 characters. Reuse it on a retry so the message is never stored twice. |
| botcheck | A hidden field people never see. Any value quarantines the message as spam. |
| _redirect | Optional, form posts only. A page on a registered website to send the visitor to afterwards. |
| turnstile_token | Only 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.
<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.
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.
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.
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 automatically when your allowance resets; never billed as overage. |
| quarantined | Stored 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.
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_body | Send JSON or form-encoded UTF-8 fields. |
| 401 | invalid_key | The access key is unknown or revoked. |
| 403 | origin_not_allowed | The Origin header is missing or 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. |
| 422 | invalid_fields | Up to 50 fields of at most 10,000 characters each. |
| 429 | rate_limited | Too many requests from one address. Wait for Retry-After seconds. |
| 503 | temporarily_unavailable | Retry 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.