> For the complete documentation index, see [llms.txt](https://docs.limecall.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.limecall.com/callback/connect-your-own-form.md).

# Connect your own form

Auto-trigger a phone call when your existing lead form is submitted.

You already have a lead form. You do not have to replace it — have it trigger a phone call as well, so a submitted form rings your team within seconds instead of sitting in an inbox.

This is the highest-value integration the widget has. Speed of response is the single biggest driver of inbound lead conversion, and a form that rings you immediately beats one that emails you every time.

```mermaid
sequenceDiagram
    participant V as Visitor
    participant P as Your page
    participant Y as Your backend
    participant L as LimeCall
    V->>P: Submits your form
    P->>Y: Saves the lead, as it always did
    P->>L: requestCallback(phone)
    L-->>P: {ok, id}
    P->>V: "We're calling you now"
    L->>V: Phone rings
    V->>L: Answers
    L->>L: Bridges to your team or AI
```

## Two ways

| Approach                                          | Use when                                                                     |
| ------------------------------------------------- | ---------------------------------------------------------------------------- |
| [Ask the widget](#ask-the-widget)                 | The widget is installed. Shortest path, and it handles validation for you.   |
| [Hand off to the widget](#hand-off-to-the-widget) | You want the widget's own confirmation screen and ringing state.             |
| [Post directly](#post-directly)                   | You want your own interface end to end, or you are submitting from a server. |

## Ask the widget

If the widget is already on the page, `requestCallback()` does the whole thing and tells you what happened:

```html
<form id="enquiry">
  <input name="name" placeholder="Your name" required>
  <input name="phone" type="tel" placeholder="Phone number" required>
  <button type="submit">Request a callback</button>
</form>
<p id="enquiry-status" role="status"></p>

<script>
document.getElementById("enquiry").addEventListener("submit", async function (e) {
  e.preventDefault();
  var status = document.getElementById("enquiry-status");
  status.textContent = "Requesting your call…";

  var result = await window.LimeCall.requestCallback(e.target.phone.value, {
    name: e.target.name.value
  });

  status.textContent = result.ok
    ? "We're calling you now — please keep your phone nearby."
    : result.message;
});
</script>
```

No key in your code, no endpoint to get right, and the phone number is validated before anything leaves the browser. It resolves `{ ok, id, message }` and never rejects, so there is nothing to catch.

## Hand off to the widget

To show the widget's own confirmation and ringing state instead of your own, pass the number to `call()` and let it take over:

```js
document.getElementById("enquiry").addEventListener("submit", function (e) {
  e.preventDefault();
  if (window.LimeCall) window.LimeCall.call(e.target.phone.value);
});
```

`call()` returns a boolean that tells you nothing about the outcome — listen for `callback:requested` and `callback:failed`, or use `requestCallback()` above.

Pair either with `hideLauncher()` if you do not want the floating button on the page.

## Post directly

To keep your own interface entirely, post to the widget's public endpoint. This uses your **widget key** — the publishable one from your snippet's `data-key` — not a secret API key.

```js
async function requestCallback(fields) {
  const res = await fetch("https://dashboard.limephone.io/api/public/callback", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Widget-Key": "YOUR_WIDGET_KEY"
    },
    body: JSON.stringify(fields)
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.message || "Callback request failed");
  return body;            // { id: "..." }
}
```

Use the same host as your snippet's `data-api` value.

### Fields

| Field        | Required | Notes                                                                                                                             |
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `phone`      | yes      | The number to call back, international format.                                                                                    |
| `name`       | no       | Shown to whoever takes the call.                                                                                                  |
| `email`      | no       | Stored on the resulting lead.                                                                                                     |
| `department` | no       | Routes to a specific team.                                                                                                        |
| `captured`   | no       | Extra fields you collected, carried onto the lead.                                                                                |
| `sessionId`  | no       | Ties the request to a widget session for attribution. If the widget is on the page, get it from `window.LimeCall.getVisitorId()`. |

Success returns the callback's `id`. A failure returns a `message` worth showing the visitor — it is where "outside business hours" arrives.

### Scheduling instead

Same shape plus a time, to a different path:

```js
fetch("https://dashboard.limephone.io/api/public/callback/schedule", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Widget-Key": "YOUR_WIDGET_KEY" },
  body: JSON.stringify({ phone: "+447700900123", scheduledAt: "2026-09-15T09:30:00Z" })
});
```

## Worked example — an existing lead form

A complete drop-in: keep your form and its styling, add a call request, and fall back gracefully when nobody can be reached.

```html
<form id="lead-form">
  <input name="name"    placeholder="Name" required>
  <input name="email"   placeholder="Email" type="email" required>
  <input name="phone"   placeholder="Phone" type="tel" required>
  <textarea name="message" placeholder="How can we help?"></textarea>
  <button type="submit" id="lead-submit">Send &amp; get a call back</button>
</form>
<p id="lead-status" role="status"></p>

<script>
(function () {
  var form   = document.getElementById("lead-form");
  var button = document.getElementById("lead-submit");
  var status = document.getElementById("lead-status");

  form.addEventListener("submit", async function (e) {
    e.preventDefault();
    button.disabled = true;                         // never double-submit
    status.textContent = "Requesting your call…";

    var data = Object.fromEntries(new FormData(form));

    // 1. Your own backend still gets the lead, exactly as before.
    try {
      await fetch("/api/leads", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data)
      });
    } catch (err) {
      // Don't abandon the call request just because your own endpoint failed.
      console.error("Lead save failed", err);
    }

    // 2. Then ask LimeCall to ring both sides.
    try {
      const res = await fetch("https://dashboard.limephone.io/api/public/callback", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-Widget-Key": "YOUR_WIDGET_KEY"
        },
        body: JSON.stringify({
          phone: data.phone,
          name:  data.name,
          email: data.email,
          captured: { message: data.message }       // carried onto the lead
        })
      });
      const body = await res.json();

      if (res.ok) {
        status.textContent = "Thanks — we're calling you now. Please keep your phone nearby.";
      } else {
        // Closed, blocked, or an unsupported country. Say what the API said.
        status.textContent = body.message || "We've got your details and will be in touch.";
        button.disabled = false;
      }
    } catch (err) {
      status.textContent = "We've got your details and will be in touch.";
      button.disabled = false;
    }
  });
})();
</script>
```

### What that example gets right

**Your backend still receives the lead.** The call request is added alongside, not instead. If LimeCall is unreachable you have still captured the enquiry.

**It tells the visitor a call is coming.** Someone who does not expect a call does not answer an unknown number, and the whole point is lost. Say it before the phone rings.

**It shows the API's own message on failure.** "We're closed right now" is useful; "Something went wrong" is not.

**It disables the button.** A visitor who clicks twice otherwise gets rung twice.

**`captured` carries the free-text field** onto the lead, so whoever picks up can see what was asked before they speak.

## Only promise a call someone can take

Ask before you offer. `hasAvailableAgents()` accounts for working hours, whether the people the widget routes to still resolve, and — on an AI-routed widget — whether you have minutes left:

```js
if (await window.LimeCall.hasAvailableAgents()) {
  button.textContent = "Send & get a call back";
} else {
  button.textContent = "Send";     // same form, no promise of a call
}
```

Changing the label is better than hiding the option. The lead is still captured either way; the visitor just is not told to expect a ringing phone that never comes.

If you are posting directly rather than using the widget, the request still succeeds outside hours — the response `message` is where "we're closed right now" arrives, which is why the example above shows it to the visitor verbatim.

## Which key is which

{% hint style="warning" %}
The **widget key** (`data-key`) is publishable — it is already in your page source and belongs in browser code. A **secret key** (`sk_live_…`) is not: putting one in front-end code exposes your whole account to anyone who opens developer tools. The widget endpoints take `X-Widget-Key`; the REST API takes `Authorization: Bearer`. They are not interchangeable.
{% endhint %}

See [API keys](/developers/api-keys.md).

## Doing it server-side instead

If your form already posts to your own backend, create the callback there. It is the right choice when you want to validate, deduplicate, enrich or rate-limit before spending a call — and the visitor's browser never has to be trusted.

It is the **same endpoint**, with a secret key instead of a widget key:

|              | Browser                         | Your server                       |
| ------------ | ------------------------------- | --------------------------------- |
| Header       | `X-Widget-Key: pk_live_…`       | `Authorization: Bearer sk_live_…` |
| Origin       | Must be on your allowed list    | Not checked                       |
| `fromNumber` | From your saved widget settings | **Required in the body**          |
| Daily cap    | Your widget's cap (default 50)  | `dailyCallbackCap`, default 200   |

{% hint style="warning" %}
The secret key needs the **`calls:write`** scope. Without it the request is rejected with `403 insufficient_scope` — placing a call spends real money, so it is held to the same scope requirement as every `/api/v1` route.
{% endhint %}

### Node

```js
// Node 18+. Never expose sk_live_ to a browser.
export async function requestCallback({ phone, name, email }) {
  const res = await fetch("https://dashboard.limephone.io/api/public/callback", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.LIMECALL_SECRET_KEY}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      phone,                                  // E.164, e.g. +447700900123
      fromNumber: process.env.LIMECALL_FROM,  // required, and must be a number you own
      name,
      email
    })
  });

  const body = await res.json();
  if (!res.ok) {
    // body.error is a stable code; body.message is safe to show a visitor.
    throw new Error(`${body.error}: ${body.message}`);
  }
  return body.id;                             // the callback id
}
```

### PHP

```php
<?php
function limecall_request_callback(string $phone, ?string $name = null): string {
    $payload = array_filter([
        "phone"      => $phone,                      // E.164
        "fromNumber" => getenv("LIMECALL_FROM"),     // required
        "name"       => $name,
    ]);

    $ch = curl_init("https://dashboard.limephone.io/api/public/callback");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer " . getenv("LIMECALL_SECRET_KEY"),
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS => json_encode($payload),
    ]);

    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $body = json_decode($raw, true);
    if ($status >= 400) {
        throw new RuntimeException("{$body['error']}: {$body['message']}");
    }
    return $body["id"];
}
```

### What you can send

`phone` and `fromNumber` are required. `name`, `email`, `department`, `captured` and `sessionId` behave exactly as they do from the browser.

The server path also accepts routing overrides the browser cannot set, so one key can serve several brands or campaigns: `dailyCallbackCap`, `bridgeProvider` (`"vapi"` or `"grok"`), `voiceAgentId`, `callableCountries`, `blockedCountries` and `blockVoip`.

### Errors worth handling

| Status | `error`               | Means                                           |
| ------ | --------------------- | ----------------------------------------------- |
| 400    | `missing_from_number` | `fromNumber` was not sent.                      |
| 401    | `unauthorized`        | Key missing, malformed or revoked.              |
| 403    | `insufficient_scope`  | The key lacks `calls:write`.                    |
| 403    | `from_not_owned`      | That caller ID is not a number on your account. |
| 403    | *(destination)*       | The number is in a range we refuse to dial.     |
| 429    | `spend_cap`           | The daily cap is spent.                         |

{% hint style="info" %}
`from_not_owned` exists because `fromNumber` comes from your request body on this path. The check stops a leaked key being used to spoof someone else's caller ID.
{% endhint %}

See [API keys](/developers/api-keys.md) for creating a key with the right scope.
