Formhook docs
Paste your API key into a form action, get submissions in your dashboard, get a system push notification for each new entry.
Quickstart
1. Sign up and verify your email.
2. Create a form in your dashboard. Copy the API key (looks like fh_…).
3. Paste it as the action of your HTML form. That's it.
HTML form (no JavaScript)
The simplest integration. Native form submission, optional redirect on success.
<form action="https://formhook.app/f/YOUR_API_KEY" method="POST">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<!-- honeypot: bots fill this, humans don't see it -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="position:absolute;left:-9999px">
<!-- where to send the user after success -->
<input type="hidden" name="_redirect" value="https://example.com/thanks">
<button type="submit">Send</button>
</form>JavaScript fetch
await fetch("https://formhook.app/f/YOUR_API_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "user@example.com",
message: "Hello",
}),
});React component
"use client";
import { useState } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
const data = Object.fromEntries(new FormData(e.currentTarget));
const res = await fetch("https://formhook.app/f/YOUR_API_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setStatus(res.ok ? "ok" : "error");
}
if (status === "ok") return <p>Thanks — we'll be in touch.</p>;
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={status === "sending"}>Send</button>
{status === "error" && <p>Something went wrong. Try again.</p>}
</form>
);
}WordPress
Formhook is a form backend, so any WordPress form can send to it. Pick whichever matches your setup:
Native HTML form
Drop a plain form into a page or block and point its action at your endpoint. Add your site’s origin to the form’s Allowed origins so browser submissions are accepted.
<form action="https://formhook.app/f/YOUR_API_KEY" method="POST">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<input type="hidden" name="_redirect" value="https://your-site.com/thanks">
<button type="submit">Send</button>
</form>Contact Form 7
Forward each Contact Form 7 submission from your theme’s functions.php. Because the request comes from your server (no browser Origin), authenticate it with an X-Auth-Token:
// functions.php — forward Contact Form 7 submissions to Formhook
add_action('wpcf7_before_send_mail', function ($contact_form) {
$submission = WPCF7_Submission::get_instance();
if (!$submission) return;
$data = $submission->get_posted_data();
wp_remote_post('https://formhook.app/f/YOUR_API_KEY', [
'headers' => [
'Content-Type' => 'application/json',
'X-Auth-Token' => 'YOUR_AUTH_TOKEN',
],
'body' => wp_json_encode($data),
'timeout' => 10,
]);
});Gravity Forms
Use the official Webhooks add-on to POST to your endpoint, or forward from functions.php with the same server-side token:
// functions.php — forward Gravity Forms submissions to Formhook
add_action('gform_after_submission', function ($entry, $form) {
$payload = [];
foreach ($form['fields'] as $field) {
$payload[$field->label] = rgar($entry, (string) $field->id);
}
wp_remote_post('https://formhook.app/f/YOUR_API_KEY', [
'headers' => [
'Content-Type' => 'application/json',
'X-Auth-Token' => 'YOUR_AUTH_TOKEN',
],
'body' => wp_json_encode($payload),
'timeout' => 10,
]);
}, 10, 2);Generate the token under your form’s Settings → Server request authentication. See Origins & authentication for the difference between browser and server requests.
cURL (testing)
Server-to-server requests (curl, backend code) send no Origin header, so they must authenticate with an X-Auth-Token header. Generate a token under your form's Settings → Server request authentication and pass it on every request — without it, server requests are rejected.
curl -X POST https://formhook.app/f/YOUR_API_KEY \
-H "Content-Type: application/json" \
-H "X-Auth-Token: YOUR_AUTH_TOKEN" \
-d '{"email":"test@example.com","message":"hi"}'Reserved fields
These three field names are stripped from the stored payload:
_gotcha— honeypot. Anything non-empty here returns 200 success but silently drops the submission and doesn't consume your quota._redirect— URL to redirect to on success (form-encoded posts only). Must be on an origin in your form's allowlist; otherwise ignored.cf-turnstile-response— Turnstile token, validated server-side.
CORS
For browser submissions, add the origin (scheme + host, no path) to your form's Allowed origins in settings. An empty list rejects browser-origin requests entirely. Server-to-server requests (no Origin header, such as curl or a backend) must send a valid X-Auth-Token header generated in the form's settings; without a token they are rejected.
Cloudflare Turnstile (optional)
Enable Turnstile in your form's settings, then embed the widget in your HTML:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async defer></script>
<form action="https://formhook.app/f/YOUR_API_KEY" method="POST">
<input name="email" type="email" required>
<div class="cf-turnstile" data-sitekey="YOUR_TURNSTILE_SITEKEY"></div>
<button type="submit">Send</button>
</form>File uploads (Pro and Studio)
Forms can accept file attachments. Submit with enctype="multipart/form-data"; each <input type="file"> field is uploaded to object storage and linked from the submission in your dashboard. Files are downloaded via short-TTL signed URLs from the dashboard; nothing is publicly addressable.
<!-- enctype is the important bit -->
<form action="https://formhook.app/f/YOUR_API_KEY" method="POST" enctype="multipart/form-data">
<input name="email" type="email" required>
<textarea name="message" required></textarea>
<!-- one or more file inputs; same field name = multiple files -->
<input name="attachment" type="file">
<button type="submit">Send</button>
</form>Per-file cap: 10 MB. Per-account storage: 1 GB on Pro, 10 GB on Studio. Soft-fail responses: {ok: true, warnings: ["file_too_large"]} when a single file exceeds the per-file cap, ["storage_full"] when the account is at its allowance, ["files_not_configured"] if the operator hasn't provisioned object storage. In every case the text submission still saves.
Studio: provision and transfer client forms
Studio accounts can build a form for a client and hand it over. The studio configures and tests while owning the form; once transferred, the client owns the form and its submissions and the studio sees metadata only.
- On New form, fill the optional Client email field. The form is marked as a client form (sponsorship is recorded) but you still own it.
- Configure and test the form normally — submissions land in your dashboard while you're testing.
- When you're ready, open the form's Settings → Client transfer section and send the invite. The form stays live and continues to accept submissions while you wait.
- Your client receives an emailed link, signs up (the email is auto-verified by token possession), and clicks Claim. From that point on, submissions flow to their dashboard.
After the claim, you see the form in your Clients console with status + monthly submission counts. You no longer see submission content or attachments — that's the GDPR boundary that keeps you out of your clients' data chain. Submissions still count against your Studio cap (the billing rolls up); the client themselves can stay on Free without breaking anything.
Import submissions
Bulk-import submissions into a form from a JSON file — open the form in your dashboard, click Import, and upload a file shaped like this:
{
"schemaVersion": 1,
"submissions": [
{
"payload": { "email": "ada@example.com", "message": "Hello" },
"createdAt": "2025-01-02T15:04:05Z"
}
]
}The top level may be an array of entries or an object with a submissions array. Each entry needs a payload object; createdAt (ISO-8601) is optional and preserved when present. Imported submissions are marked read, never trigger notifications or webhooks, and are capped at 5,000 per file (max 5 MB).
Rate limits and quotas
- Per-IP across all forms: 10 requests/minute → 429 with
Retry-After. - Per-form: 60 requests/minute → 429.
- Body size cap: 64 KB → 413.
- Free tier monthly quota: 250 submissions over a rolling 30-day window. Over-quota still returns 200 with
warnings: ["over_quota"]in the body — submissions are never silently dropped.
Error responses
| HTTP | code | meaning |
|---|---|---|
| 200 | ok | success |
| 302 | — | redirect to _redirect URL |
| 400 | invalid_body | malformed JSON / unsupported content-type |
| 403 | origin_not_allowed | CORS allowlist mismatch |
| 403 | turnstile_failed | Turnstile token missing/invalid |
| 403 | account_suspended | form owner is suspended |
| 404 | form_not_found | unknown api_key |
| 413 | body_too_large | body > 64 KB |
| 429 | rate_limited | includes Retry-After header |
| 500 | internal_error | try again, or report |
Push notifications (for you, the form owner)
Open your dashboard and click Enable notifications — your browser will ask for permission, then every new submission triggers a system notification, even when the tab is closed. On iOS, install Formhook to your home screen first (Safari 16.4+).