> For the complete documentation index, see [llms.txt](https://docs.shortcutco.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.shortcutco.com/developers/guides/push-website-leads.md).

# Push website leads

Send every request from your own website's contact form to Shortcut as a lead, safely retried and never duplicated.

After this guide, every request for service submitted on your website lands in Shortcut as a lead within a second: staff are notified, the address becomes the customer's property, and a retried submission never creates a second lead. It takes about 20 minutes and a server-side form handler.

Use this guide when your website is not built with Shortcut and already has its own contact form. If you want a form without writing code, Shortcut's own lead capture form under **Settings → Lead capture** does the same job.

## 1. Create a token for the website

In **Settings → API & integrations**, select **Create token**:

* **Name:** `Website leads`.
* **Scopes:** choose **Custom**, then turn on **Write leads** and **Write customers**. Write customers lets a returning customer ask for more work: without it, a request whose email belongs to an existing customer is refused. Turn on **Trigger automations** too if the new-lead welcome automation should greet each new lead, as it does for leads from Shortcut's own form. Without it, a lead created through the API starts none of your **Automations** rules or the new-lead welcome automation. See [automations:trigger](/developers/concepts/scopes.md#automationstrigger).
* **Expiry:** 1 year, with a reminder to rotate it.

Store the token as `SHORTCUT_TOKEN` in your website's server environment, never in the page.

## 2. Map your form to a lead

`POST /v1/leads` takes:

| Field                   | Required | From your form                                                                 |
| ----------------------- | -------- | ------------------------------------------------------------------------------ |
| `firstName`, `lastName` | Yes      | Name. Split a single name field at the first space. Up to 100 characters each. |
| `email`                 | Yes      | Email. Shortcut uses it to match the lead to an existing customer.             |
| `address`               | Yes      | The service address on one line: `412 Elm St, Bozeman, MT 59715`.              |
| `phone`                 | No       | Phone, as typed. US numbers may leave out the country code.                    |
| `companyName`           | No       | Company, for commercial requests.                                              |
| `servicesRequested`     | No       | Checkboxes such as `Weekly mowing` or `Aeration`, up to 50.                    |
| `specialRequests`       | No       | The free-text message, up to 2,000 characters.                                 |

Send nothing else: the body is strict, so an extra field, such as a form id, is refused with `400 validation_failed`.

## 3. Send it from your server

Try it once with `curl`:

```bash
curl https://api.shortcutco.com/v1/leads \
  -X POST \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: website-form-0c41f7a2" \
  -d '{
    "firstName": "Jamie",
    "lastName": "Chen",
    "email": "jamie.chen@example.com",
    "phone": "406-555-0177",
    "address": "88 Willow Way, Bozeman, MT 59718",
    "servicesRequested": ["Aeration", "Overseeding"],
    "specialRequests": "Dog in the backyard, please text before arriving."
  }'
```

```json
{
  "data": {
    "id": "e5d4c3b2-a190-4f8e-8d7c-6b5a49382716",
    "object": "lead",
    "firstName": "Jamie",
    "lastName": "Chen",
    "companyName": null,
    "email": "jamie.chen@example.com",
    "phone": "+14065550177",
    "address": "88 Willow Way, Bozeman, MT 59718",
    "servicesRequested": ["Aeration", "Overseeding"],
    "specialRequests": "Dog in the backyard, please text before arriving.",
    "estimatedHours": null,
    "sqFootage": null,
    "status": "pending",
    "lifecycleStage": "lead",
    "source": "api",
    "customerId": "2a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
    "propertyId": "7f8e9d0c-1b2a-4c3d-8e4f-5a6b7c8d9e0f",
    "externalReferences": [],
    "createdAt": "2026-10-16T19:22:40.118Z",
    "updatedAt": "2026-10-16T19:22:40.131Z"
  }
}
```

The status is `201 Created`. `customerId` and `propertyId` are the customer account and property Shortcut created for the new email, or the existing customer's when the email was already known.

Then wire the same call into your form handler. This one is for any Node.js server, with Express shown; `pushLead` works the same in a Next.js route handler or a serverless function, because it waits at most 10 seconds and leaves longer waits to your retry queue:

Install Express first:

```bash
npm install express
```

```js
// leads.mjs: Node 18 or later.
import { randomUUID } from "node:crypto";
import express from "express";

const MAX_WAIT_SECONDS = 10;

export async function pushLead(submission) {
  const [firstName, ...rest] = submission.name.trim().split(/\s+/);
  const body = {
    firstName,
    lastName: rest.join(" ") || "-",
    email: submission.email,
    address: submission.address,
    ...(submission.phone ? { phone: submission.phone } : {}),
    ...(submission.company ? { companyName: submission.company } : {}),
    servicesRequested: submission.services ?? [],
    ...(submission.message ? { specialRequests: submission.message.slice(0, 2000) } : {}),
  };

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch("https://api.shortcutco.com/v1/leads", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SHORTCUT_TOKEN}`,
        "Content-Type": "application/json",
        // One key per submission: every retry of this submission reuses it.
        "Idempotency-Key": `website-form-${submission.id}`,
      },
      body: JSON.stringify(body),
    });
    const payload = await response.json();
    if (response.ok) return payload.data;

    const { code, message, requestId } = payload.error;
    if (response.status === 409 || response.status === 429 || response.status >= 500) {
      const wait = Number(response.headers.get("retry-after")) || 2 ** attempt;
      if (wait > MAX_WAIT_SECONDS) break; // Leave it to your retry queue.
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    // 400, 401, 402, 403, 422: retrying will not help. Log and alert.
    throw new Error(`Shortcut refused the lead: ${code}: ${message} (request ${requestId})`);
  }
  throw new Error("Shortcut could not take the lead right now; retry this submission later.");
}

const app = express();
app.use(express.urlencoded({ extended: false }));

app.post("/contact", async (request, response) => {
  const submission = {
    // A unique id per submission. Prefer one your form or database assigns,
    // so a resubmitted form keeps the same id.
    id: request.body.submissionId ?? randomUUID(),
    name: request.body.name,
    email: request.body.email,
    phone: request.body.phone,
    address: request.body.address,
    services: [].concat(request.body.services ?? []),
    message: request.body.message,
  };
  try {
    await pushLead(submission);
    response.redirect(303, "/thanks");
  } catch (error) {
    console.error(error);
    // Store the submission for your retry job; retrying with the same id keeps it single.
    await saveForRetry(submission);
    response.redirect(303, "/thanks");
  }
});

// Replace with your database or queue. A scheduled job calls pushLead(submission)
// for each stored submission and deletes it once Shortcut accepts it.
async function saveForRetry(submission) {
  console.log("stored for retry", submission.id);
}

app.listen(3000);
```

Three details make this reliable:

* **One idempotency key per submission.** `website-form-<submission id>` stays the same across retries, so a timeout, a double click, or your own retry job never creates a second lead. Keys last 24 hours. A visitor who changes the form and submits again has made a new submission: give it a new id, and so a new key, or Shortcut answers `422 idempotency_key_reused_with_different_payload`.
* **Retry only what can succeed.** `409`, `429`, and `5xx` answers are retried after `Retry-After`. Anything else is a problem to fix, and retrying it with the same key replays the same answer.
* **Never lose a submission.** If Shortcut cannot take the lead right now, `saveForRetry` stores the submission, and a scheduled job sends it again with the same key.

## 4. Handle the answers

| Answer                                                           | What it means                                                                                       | What to do                                                              |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `201`                                                            | The lead was created.                                                                               | Nothing.                                                                |
| `201` with `Idempotent-Replayed: true`                           | This submission was already sent. You got the original lead back.                                   | Nothing.                                                                |
| `400 validation_failed`                                          | A field is missing or malformed; `param` says which, such as `body.email` or `body.phone`.          | Validate the field in your form. For a corrected resend, use a new key. |
| `403 insufficient_scope` with `requiredScope: "customers:write"` | The email belongs to an existing customer and the token lacks Write customers. Nothing was created. | Create a token with Write customers and replace this one.               |
| `429 workspace_rate_limited`                                     | More than 20 leads a minute reached Shortcut through the API.                                       | Wait `Retry-After` seconds and retry with the same key.                 |
| `401 token_invalid`                                              | The token was revoked or has expired.                                                               | Create a new token and update the server environment.                   |

The full list is in [Errors](/developers/concepts/errors.md).

## 5. See it in Shortcut

Open **Customers**. Jamie Chen is at the top with the **Lead** label and under **Needs estimate**, with the address, the services requested, and the special requests on the lead. Staff who get new-lead notifications were notified. From here the lead moves through Shortcut like any other: estimate, approval, jobs, visits.

## Keep spam out

Shortcut creates a lead for every request you send, so filter spam before you call it: use your form provider's spam protection or a CAPTCHA on the page, and drop submissions with no real address.
