Docs
PostForm has one endpoint. You POST to it with an access key, it stores the submission and emails your destination address.
POST https://postform-wine.vercel.app/api/submitLast updated 2026-08-11.
Quickstart
Set your form's action to the endpoint and add your access key as a hidden input. Every other field is yours.
<form action="https://postform-wine.vercel.app/api/submit" method="POST">
<input type="hidden" name="access_key" value="your-access-key" />
<label for="name">Name</label>
<input id="name" type="text" name="name" required />
<label for="email">Email</label>
<input id="email" type="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<!-- Honeypot: hidden from people, filled in by bots. Leave it here. -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<button type="submit">Send</button>
</form>Your access key lives in the dashboard. It is public by design: it routes submissions, it is not a secret. Anyone reading your page's source can see it, which is fine. Rate limiting and your monthly quota are what protect you.
Reserved fields
These field names control how the submission is handled. Everything else is stored as your data and rendered in the notification email.
| Field | Required | Behaviour |
|---|---|---|
| access_key | Yes | Identifies your account and destination address. |
| No | Used as the Reply-To on the notification, so you can reply straight from your inbox. | |
| replyto | No | Explicit Reply-To. Takes precedence over email. |
| subject | No | Overrides the key's default subject for this submission. |
| redirect | No | Switches the response from JSON to a 303 redirect to this URL. |
| botcheck | No | The honeypot. Must stay empty. If it is filled, the submission is filtered as spam. |
Responses
Without a redirect field you get JSON. With one you get a 303 to that URL, which is what makes the zero JavaScript flow work.
{
"success": true,
"body": {
"data": { "name": "Jane", "email": "jane@example.com", "message": "Hi" },
"message": "Submission received."
}
}{
"success": false,
"body": { "data": {}, "message": "Invalid or missing access_key." }
}{
"success": false,
"message": "Monthly submission limit reached. Upgrade or wait for cycle reset."
}In redirect mode a failure sends you to the same URL with ?error=1 appended, so your success page can tell the two apart.
<input type="hidden" name="redirect" value="https://example.com/thanks" />
<!-- success -> 303 https://example.com/thanks -->
<!-- failure -> 303 https://example.com/thanks?error=1 -->JavaScript and React
You do not need this. It is here for when you want to stay on the page and render your own success state.
'use client'
import { useState } from 'react'
export function ContactForm() {
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setStatus('sending')
const res = await fetch('https://postform-wine.vercel.app/api/submit', {
method: 'POST',
body: new FormData(event.currentTarget),
})
setStatus(res.ok ? 'sent' : 'error')
}
if (status === 'sent') return <p>Thanks, we will be in touch.</p>
return (
<form onSubmit={onSubmit}>
<input type="hidden" name="access_key" value="your-access-key" />
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required />
<input type="checkbox" name="botcheck" style={{ display: 'none' }} tabIndex={-1} />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send'}
</button>
{status === 'error' && <p role="alert">Something went wrong. Please try again.</p>}
</form>
)
}Passing FormData straight through means the browser sets the content type for you. The endpoint also accepts JSON if you prefer to build the body by hand.
await fetch('https://postform-wine.vercel.app/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_key: 'your-access-key',
email: 'jane@example.com',
message: 'Hello',
}),
})Spam filtering
The botcheck field is a honeypot. People never see it, so they never fill it. Bots fill every field they find. When it comes back filled we store the submission as spam, skip the email, and do not count it against your quota.
A filtered submission gets exactly the same response as a successful one. If it did not, a bot could tell it had been caught and adapt. You can see everything that was filtered in your dashboard.
On top of that, each access key is rate limited per IP address. Bursts are refused with a 429 before they reach your inbox or your quota.
Test mode
Every form has a test mode you can turn on from its page in the dashboard. Nothing about the request changes: the same endpoint, the same access key, the same response. Your markup does not change either, so what you test is what you ship.
What changes is the bookkeeping. Test submissions still send the notification email, with [TEST] in the subject, and they are still stored so you can inspect exactly what arrived. They cost no quota, they are left out of your totals and your trend, they are badged everywhere they appear, and you can delete all of them for a form in one action.
There is no way to trigger test mode from the form itself. That is deliberate: anyone can post to your access key, so a field that skipped the quota would be a free pass around it. Only you can turn it on, and while it is on your dashboard says so in a banner you cannot dismiss.
Limits and retention
- 300 submissions every 30 days. A hard cap on a rolling 30-day window. It resets 30 days after your last reset, not on a fixed calendar day.
- Unlimited forms. Create as many access keys as you want. The quota is per account, not per key.
- 30 days of submission history. Older submissions are deleted. Your notification emails are unaffected, they live in your inbox.
- Storage first, email second. Submissions are written to the database before we attempt delivery, so a bounced address never loses you the lead.