---
title: "JavaScript fetch"
description: "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."
url: "https://formparcel.live/docs/guides/fetch"
---

# JavaScript fetch

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.

Untested: Not yet tested on a live site. The steps follow how the form address works today; if one differs, [tell us](https://formparcel.live/contact).

## 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.

```js title="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:

```html title="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](https://formparcel.live/docs/setup/spam-protection#the-bot-check).

> **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](https://formparcel.live/docs/reference/bot-check)

## Read the answer

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

```json title="200 OK application/json"
{
  "accepted": true,
  "deliveryStatus": "pending",
  "submissionId": "sub_m1x8k2q4_Zk3v..."
}
```

- **accepted boolean**: 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.
- **submissionId string, 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.

```json title="429 Too Many Requests application/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](https://formparcel.live/docs/reference/responses#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`](https://formparcel.live/docs/reference/responses#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`](https://formparcel.live/docs/reference/responses#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.

```js title="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](https://formparcel.live/docs/reference/responses#origin-not-allowed)). Keep each post to 16 KB or less.
