PortForm

Reference

Updated 2026-09-19

Docs

PortForm has one endpoint. You POST to it with an access key, it stores the submission and emails your destination address.

endpoint
POST https://app.portform.co/api/submit

Building with AI agents? The Agent & MCP guide covers the anonymous sandbox and the MCP server in full.

Quickstart

Set your form's action to the endpoint and add your access key as a hidden input. Every other field is yours.

index.html
<form action="https://app.portform.co/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 30-day 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.

FieldRequiredBehaviour
access_keyYesIdentifies your account and destination address.
emailNoUsed as the Reply-To on the notification, so you can reply straight from your inbox.
replytoNoExplicit Reply-To. Takes precedence over email.
redirectNoSwitches the response from JSON to a 303 redirect to this URL.
botcheckNoThe 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.

200 OK
{
  "success": true,
  "body": {
    "data": {},
    "message": "Submission received."
  }
}
400 Bad Request
{
  "success": false,
  "body": { "data": {}, "message": "Invalid or missing access_key." }
}
429 Too Many Requests
{
  "success": false,
  "message": "Submission limit reached. Upgrade, or wait for the 30-day window to roll over."
}

data is empty on purpose. The endpoint does not echo the submission back — read what you posted from your own form, not from the response.

In redirect mode a failure sends you to the same URL with ?error=1 appended, so your success page can tell the two apart.

redirect mode
<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.

ContactForm.tsx
'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://app.portform.co/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.

fetch with JSON
await fetch('https://app.portform.co/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

More than one check runs on every submission. The botcheck field is a honeypot: people never see it, so they never fill it, and bots fill every field they find. Alongside it we score the payload itself — throwaway submitter addresses and link-stuffed messages are the usual tells — and every access key is rate limited per IP address, so bursts are refused with a 429 before they reach your inbox or your quota.

Whichever check catches it, the result is the same: stored as spam, no email sent, nothing counted against your quota. You can see everything that was filtered in your dashboard, so nothing disappears silently.

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 also put a visible CAPTCHA on a form. hCaptcha is on every plan; reCAPTCHA and Cloudflare Turnstile are on Pro and Agency & Team. When one is enabled its token is verified server-side, and a submission that fails is rejected outright rather than filtered quietly.

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.

Destination verification

Before PortForm sends notification email to a destination address, someone has to prove they control that inbox. Until then, submissions to the form are still accepted and stored — the submitter gets a success and the lead shows up in your dashboard — but no notification email is sent to the unverified address.

This stops PortForm from being used to send mail to an inbox nobody has claimed. It only gates the notification email. Storage is unaffected, and any integrations you connected yourself still fire. Verify the address from the dashboard and email delivery switches on.

CC recipients

A form can copy extra addresses on every notification email, so a shared inbox and a personal one both get the lead. CC is on Pro and Agency & Team. On Personal the submission still goes through and the destination is still emailed — the extra recipients are simply dropped rather than the form failing.

Autoresponder

A form can send an automatic reply to whoever submitted it, using the address in the submission's email field — a “thanks, we got your message” with a subject and body you set. The autoresponder is on Pro and Agency & Team.

Domain restriction

You can limit a key to accept submissions only from the origins you list, so a copied access key posting from someone else's site is refused. Domain restriction is on Pro and Agency & Team.

It is a convenience control, not authentication. Access keys are public and an origin header can be forged, so this trims casual misuse rather than guaranteeing where a request came from — your quota, rate limiting and spam filtering are the real protections.

Integrations and webhooks

Beyond email, a submission can be forwarded to other tools. PortForm delivers natively to Slack, Discord, Telegram, Google Sheets, Notion, Airtable and HubSpot, and to any URL over a plain webhook — which is also how Zapier, Make and n8n connect. Integrations are configured per form from the dashboard and are on Pro and Agency & Team, up to 25 per account.

Delivery runs after the submission is stored and off the response path: the submitter never waits on it, and an integration is not a fallback for email — each connected destination gets its own copy. Every attempt is logged, with a success rate and retry queue you can see in the dashboard.

Delivery retries

Not every step retries, and it helps to keep them apart. Storage does not need to: a submission is written to the database before anything else, so it is never lost to a failed send. The notification email is best-effort. What retries automatically is onward integration delivery.

A failed integration delivery is retried up to 5 times over roughly two and a half hours, with the gaps between attempts widening each time. Only transient failures qualify — a timeout, a connection reset, an HTTP 5xx, or a 408 or 429. A plain 4xx means the receiver is rejecting the request itself, which will not change on a retry, so PortForm stops and shows the error in the delivery log.

Exporting submissions

You can export the submissions behind any filtered view from the dashboard, on every plan. CSV is shaped for a spreadsheet — payload fields become columns, unioned across the rows so a form whose fields changed over time still exports as one table, and cell values that start with = are neutralised so a name field can never run as a formula in Excel. JSON gives you the same rows with each submission's full payload, for a script.

MCP

PortForm runs a Model Context Protocol server, so an AI agent can manage your forms directly. It is available on every plan. Point an MCP client at the endpoint with a workspace token minted in the dashboard; the token carries your own role and is re-checked on every request, so an agent can never do more through MCP than you can.

mcp endpoint
POST https://app.portform.co/api/v1/mcp

Eight tools are available: list_forms, get_form, create_form, update_form, list_submissions, get_submission, test_submission (sends one real test submission), and diagnose (lints your form markup and reports wiring problems). Reading submissions returns metadata only — no tool returns submission payloads or a submitter's email address. Billing, members, linked emails and integrations are not reachable over MCP; they stay in the dashboard.

Per-agent setup for Claude Code, Cursor and others is on the Agent & MCP guide.

Agent sandbox

An AI agent can build and prove a working form before anyone signs up. It creates a sandbox anonymously — no account and no token — and gets back a real access key and submit URL. The flow is create → wire → test → verify → claim: wire the key into your markup, send test submissions, check they arrived, then hand the human a claim link.

A sandbox accepts up to 5 test submissions, which are stored so you can inspect them but never emailed, and it costs no account quota. It is temporary: an unclaimed sandbox expires after 24 hours. Claiming it is single-use — the human opens the link and signs in, and the form becomes theirs with the same id and submit URL. Its earlier test submissions stay in the account marked as tests.

Full details and the claim flow are on the Agent & MCP guide.

Limits and retention

  • 300 submissions every 30 days on Personal. A hard cap on a rolling 30-day window. It resets 30 days after your last reset, not on a fixed calendar day. Pro raises it to 10,000 and Agency & Team to 20,000.
  • Unlimited forms. Create as many access keys as you want. The quota is per account, not per key.
  • 3 files per submission, 5 MB each. File uploads are a Pro feature. On Personal the submission itself still goes through — the files are dropped rather than the form failing.
  • 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.

We use optional analytics cookies to understand how the site is used. Necessary cookies for signing in and checkout always load. See our privacy policy.