Docs
Theme
Start free
  1. Send the lead
  2. Read the answer
  3. Handle a refusal
  4. When to retry
  5. Spam check
  6. When you get no answer
  7. Calling from a browser

JavaScript fetch

Untested: Not yet tested on a live site. The steps follow how the form address works today; if one differs, tell us.

Send a lead with fetch and keep the visitor on the page. A 200 means the lead is stored. Read deliveryStatus, and never resend a pending lead.

Send the lead

POST a JSON object to your form address. Anything that is not a browser page navigation (fetch, XHR, curl, a server) gets JSON back, for success and for errors.

Example form address. Use yours from the dashboard.

send-lead.js
// FormParcel's bot check: load this once on the page (no cookies, no widget). Without it, submissions wait in your Spam folder.
//   <script src="https://f.formparcel.live/f/formparcel.js" async></script>
async function submitLead(data) {
  // One proof per submission. The bot check may still be loading on a slow phone, so wait for it (10 seconds at most). If it cannot load, the lead is still sent and waits in your Spam folder.
  const fp = window.FormParcel || await new Promise((ok) => { addEventListener("formparcel:ready", () => ok(window.FormParcel), { once: true }); setTimeout(ok, 10000); });
  const _fp_proof = await fp?.proof("https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE").catch(() => undefined);
  const response = await fetch("https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({ ...data, _fp_proof }),
  });

  // 204: the post was dropped as spam (the trap field was filled). Nothing was saved.
  if (response.status === 204) return { accepted: false };

  const result = await response.json();
  // Accepted: { accepted: true, deliveryStatus: "delivered" | "pending", submissionId: "sub_..." }
  // "pending" means the lead is saved and delivery is still in progress. Do not send it again.
  // Refused: { success: false, error: "...", code: "..." } with a 4xx status. A 429 or 503 says when to retry (Retry-After).
  return result;
}

Load FormParcel’s bot check once on the page, and ask it for a proof before every send:

On the page
<script src="https://f.formparcel.live/f/formparcel.js" async></script>

window.FormParcel.proof(address) gives one proof for one submission. Send it as the _fp_proof field of the JSON body. It resolves once the proof is ready and at least 2 seconds old, so the first send after the page loads can wait a moment. A lead sent without a valid proof is still saved, and it waits in your Spam folder instead of being delivered. A proof sent a second time is held in your Spam folder too, so ask for a new one before every send. See Spam protection.

Note A 200 means Accepted, not Delivered. Delivery runs after the answer. The receipt ID finds the lead.

A browser form post gets a page or a redirect instead, and a request counts as one only when it accepts text/html, its Sec-Fetch-Mode is missing or navigate, and it did not ask for JSON. A fetch call never does, even with a form-encoded body that sets _next.

Bot check. A lead sent without FormParcel's bot check is saved but held in your Spam folder, not delivered. That is every post from a server, a webhook or curl, and every post from a page that does not load the script. The dashboard then shows Bot check not installed under Needs action. Fix: load the script on the page and send the proof from FormParcel.proof() with each fetch. Learn more

Read the answer

An accepted lead comes back with status 200. The lead is saved before this answer is sent.

200 OKapplication/json
{
  "accepted": true,
  "deliveryStatus": "pending",
  "submissionId": "sub_m1x8k2q4_Zk3v..."
}
acceptedboolean
Always true on a 200.
deliveryStatus"delivered" or "pending"
How far delivery got in the 2 seconds FormParcel waits before it answers. delivered: every destination confirmed in time and no Sheet column was waiting for review. pending: the lead is stored and delivery carries on.
submissionIdstring, starts with sub_
The receipt ID. Quote it when you ask about a lead.

Both values are success. Show your thank-you message either way. Do not send a pending lead again: it is stored and FormParcel keeps delivering it, and a second post makes a second lead with its own receipt ID.

Handle a refusal

A refused request has a 4xx or 5xx status and a JSON body with code, error and "success": false. A few refusals send no code. The error text is written for people and can change: branch on the HTTP status, then on code. No refused request creates a receipt.

429 Too Many Requestsapplication/json
{
  "success": false,
  "error": "This form cannot take more messages this month. Please contact the business another way.",
  "code": "PLAN_LIMIT_REACHED"
}

A 204 is not an error. It means the post was dropped as spam because the trap field was filled. Nothing was stored, so show no success message. A bot check proof sent a second time is different: that lead is stored and held in the Spam folder, and the answer is the usual 200. A real visitor never fills the trap field in, so leave it out of your JSON unless you fill it on purpose, and get a new proof for every send.

Every status and code, with what to do about it, is on Responses and error codes.

When to retry

  • 200: never send the lead again, whatever deliveryStatus says.
  • 429: wait the seconds in Retry-After, then try once more. Not for PLAN_LIMIT_REACHED, which has no Retry-After.
  • 500 or 503: try once more. A 503 asks you to wait 5 seconds.
  • 403 with SPAM_CHECK_FAILED: ask the visitor to complete the spam check, then send the post with the fresh token.
  • Any other 4xx: do not retry. The same request fails the same way. Fix the request or the form’s settings.

Spam check

When the client site has the Cloudflare Turnstile check on, the form must send the widget’s token in the cf-turnstile-response field. A token works once, so ask the widget for a fresh one after every post. Keep sending the bot check’s proof too: it decides whether the lead is delivered when Cloudflare cannot answer.

send-lead-with-spam-check.js
// Load https://challenges.cloudflare.com/turnstile/v0/api.js once, and put
// <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div> on the page with your form.
// Keep FormParcel's bot check too (no cookies, no widget):
//   <script src="https://f.formparcel.live/f/formparcel.js" async></script>
async function submitLead(data) {
  const fp = window.FormParcel || await new Promise((ok) => { addEventListener("formparcel:ready", () => ok(window.FormParcel), { once: true }); setTimeout(ok, 10000); });
  const _fp_proof = await fp?.proof("https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE").catch(() => undefined);
  const response = await fetch("https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE", {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({ ...data, _fp_proof, "cf-turnstile-response": window.turnstile?.getResponse() }),
  });
  window.turnstile?.reset(); // a token works once: ask the widget for a fresh check

  const result = await response.json();
  if (response.status === 403 && result.code === "SPAM_CHECK_FAILED") {
    // The visitor did not pass the check. Ask them to complete it and send again.
  }
  return result;
}

When you get no answer

A timeout or a dropped connection does not tell you whether the lead was accepted. FormParcel has no caller-supplied idempotency key yet, so sending the same lead again can create a second receipt. For a contact form, the safer choice is to tell the visitor it may not have gone through and let them decide. A 503 is different: it says the lead was not accepted.

Calling from a browser

FormParcel answers the CORS preflight: it allows POST, OPTIONS and the headers Content-Type, Accept, Authorization, so fetch from your site works without a proxy. If you set allowed origins on the form, a request from any other origin is refused (origin not allowed). Keep each post to 16 KB or less.

Something wrong on this page? Tell us