> ## Documentation Index
> Fetch the complete documentation index at: https://api.leadey.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Writing data

> Create leads, add notes, log calls and manage tasks through the API — and how those writes are attributed.

The API reads and writes. Reads work with any key; writes need a key with **write access**, and every write is attributed to a real member of your workspace rather than to "the API".

## Key scopes

Every API key has one of two scopes, chosen when you create it.

| Scope              | What it can do                                       |
| ------------------ | ---------------------------------------------------- |
| **Read only**      | Every `GET` endpoint. Cannot change anything.        |
| **Read and write** | Everything above, plus `POST`, `PATCH` and `DELETE`. |

Read only is the default, and it is the right choice for anything that only pulls data out — a reporting job, a warehouse sync, a dashboard. If that key ever leaks, the damage is disclosure rather than data loss.

<Note>
  Keys created before write access existed are read-only. Their behaviour has not changed. To write, create a new key with write access — you cannot upgrade an existing one.
</Note>

A write sent with a read-only key returns `403`:

```json theme={"dark"}
{
  "error": {
    "message": "This API key is read-only. Create a key with write access in Settings → API Keys.",
    "details": null
  }
}
```

## Creating a write key

1. In the Cockpit, go to **Settings → API Keys**.
2. Click **Create key** and give it a name that says where it will be used — `Zapier production`, `warehouse sync`.
3. Under **Access**, choose **Read and write**.
4. Copy the key. It is shown once.

The list shows each key's access alongside its name, so a write key is obvious at a glance.

## How writes are attributed

An API key belongs to a workspace member — by default whoever created it. Every write made with that key is recorded as that person's action:

* Notes show their name as the author.
* Logged calls appear on their activity.
* Tasks are assigned to them unless you pass an `assigneeId`.
* Timeline entries carry their user id.

This is deliberate. Work that arrives through an integration still has to be attributable to someone, or a manager looking at the timeline sees activity with no owner.

<Warning>
  If the member a key belongs to is removed from the workspace, the key survives but loses its actor, and writes start returning `400`. Reads keep working. Create a new key owned by a current member.
</Warning>

## Creating leads

`POST /leads` needs a `name` plus at least one of `email`, `phone` or `linkedinUrl`. Without one of those there is no way to recognise the same person later, and every subsequent call would create another copy of them.

```bash theme={"dark"}
curl -X POST https://backend.leadey.ai/v1/leads \
  -H "Authorization: Bearer $LEADEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Priya Raman",
    "email": "priya.raman@northwind.co.uk",
    "company": "Northwind Logistics",
    "title": "Head of Revenue Operations",
    "campaignId": "fnl_Q3OutboundUK",
    "source": "website-form"
  }'
```

### Retries are safe

Leads are deduplicated across the whole workspace by the person they resolve to. Post the same email twice and you get the **same lead back**, with `created: false` and a `200` instead of a `201`:

```json theme={"dark"}
{
  "data": {
    "id": "lead_8Kq2mXvR",
    "name": "Priya Raman",
    "email": "priya.raman@northwind.co.uk",
    "status": "pending",
    "created": false
  }
}
```

That is why there are no idempotency keys to manage. The natural key is better than a header here, because it also catches the same person arriving from two different integrations — a form fill and a CSV import will not produce two Priyas.

Check `created` when it matters. A webhook handler that emails "welcome" on every `POST /leads` will email the same person twice; one that emails only when `created` is true will not.

### Workflows still fire

A lead created with a `campaignId` fires the **Lead enters campaign** trigger, exactly as one added in the Cockpit does. Sequences run, so an API-created lead is never a lead that quietly sits still.

The same is true of `PATCH /leads/{id}/status`, which fires **Status changed**, and `POST /campaigns/{id}/leads`, which fires **Lead enters campaign** for each lead added.

## Updating leads

`PATCH /leads/{id}` touches only the keys you send. Omitting a field leaves it alone; sending `null` clears it. This is what makes a partial sync from another system safe — you can push the two fields you own without flattening everything else.

```bash theme={"dark"}
curl -X PATCH https://backend.leadey.ai/v1/leads/lead_8Kq2mXvR \
  -H "Authorization: Bearer $LEADEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "VP Revenue Operations",
    "customFields": { "renewal_date": "2027-03-01" }
  }'
```

Custom fields work the same way on `POST /leads`, so a lead can arrive with them already set rather than needing a second call.

A key that doesn't exist yet **is created automatically**, which means a migration doesn't have to pre-declare its columns in **Settings** first. That makes custom fields the right place to park identifiers from a system you're moving off — the original record id, its created date — so you can find your rows again afterwards:

```json theme={"dark"}
{ "customFields": { "legacy_record_id": "REC-90412", "legacy_created_at": "2023-04-11" } }
```

Keys are slugified, so `Legacy Record ID` is stored and read back as `legacy_record_id`. The response echoes the canonical keys, and `GET /leads` and `GET /leads/{id}` return `customFields` in exactly the shape you wrote — so a value round-trips unchanged. Values are stored as text; numbers, dates and booleans are coerced rather than rejected.

Status is the one field this endpoint won't take. Use `PATCH /leads/{id}/status`: it validates the key against your workspace's list, records the transition on the timeline, and fires the **Status changed** trigger. Get the valid keys from `GET /lead-statuses` — an unknown one returns `400` rather than storing a status nothing in the product recognises.

## Stopping contact

To stop contacting someone, flag them rather than deleting them:

```bash theme={"dark"}
curl -X POST https://backend.leadey.ai/v1/leads/lead_8Kq2mXvR/dnc \
  -H "Authorization: Bearer $LEADEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": true }'
```

The flag applies to the **person**, not the row: it follows them across every lead in the workspace that resolves to the same human, so one call cannot leave a duplicate un-suppressed. The response's `flaggedLeads` tells you how many rows were affected. They stay in their campaigns, and their history stays intact.

`DELETE /leads/{id}` exists, but it is permanent and takes the history with it. Reach for it only when a record should never have existed.

## Notes and calls

Notes are plain text and appear on the lead's timeline immediately:

```bash theme={"dark"}
curl -X POST https://backend.leadey.ai/v1/leads/lead_8Kq2mXvR/notes \
  -H "Authorization: Bearer $LEADEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "text": "Asked for pricing on a 240-vehicle fleet." }'
```

`POST /calls` records a call that happened somewhere else — a mobile, another dialler, a conference line — so the lead's history and your call metrics stay complete. Pass `duration` in seconds and it counts towards your talk time; `notes` becomes the call's summary.

<Warning>
  Calls placed through Leadey are recorded automatically. Do not post them here as well, or they are counted twice in your metrics.
</Warning>

Outcome keys come from `GET /call-outcomes`.

## Tasks

`GET /tasks` returns open tasks by default, soonest-due first. An integration polling for work to do wants the outstanding list, so completed tasks are excluded until you ask for them with `done=true` or `done=all`.

```bash theme={"dark"}
# Everything overdue, across the workspace
curl "https://backend.leadey.ai/v1/tasks?overdue=true" \
  -H "Authorization: Bearer $LEADEY_API_KEY"
```

New tasks default to the key's member as assignee — an unassigned task is invisible work. Complete one with `PATCH /tasks/{id}` and `{ "done": true }` rather than deleting it, so it stays on the record.

## When writes are blocked

A workspace that cannot write in the Cockpit cannot write through the API either. If the trial has expired, an invoice is long overdue, or the subscription has been cancelled, writes return `403` with a code you can branch on:

```json theme={"dark"}
{
  "error": {
    "message": "Your free trial has expired. Please upgrade to continue.",
    "details": { "code": "TRIAL_EXPIRED" }
  }
}
```

Reads keep working throughout, so a reporting integration is never taken down by a billing problem.

| Code                     | Meaning                                            |
| ------------------------ | -------------------------------------------------- |
| `PAYMENT_SETUP_REQUIRED` | No payment method on the workspace yet.            |
| `TRIAL_EXPIRED`          | The free trial has ended.                          |
| `PAYMENT_REQUIRED`       | An invoice has been overdue for more than 14 days. |
| `SUBSCRIPTION_CANCELLED` | The subscription was cancelled.                    |

## A full round trip

Creating a lead, working it, and reading back everything that happened:

```bash theme={"dark"}
API=https://backend.leadey.ai/v1
AUTH="Authorization: Bearer $LEADEY_API_KEY"
JSON="Content-Type: application/json"

# 1. Create the lead in a campaign
LEAD=$(curl -s -X POST $API/leads -H "$AUTH" -H "$JSON" \
  -d '{"name":"Priya Raman","email":"priya.raman@northwind.co.uk","campaignId":"fnl_Q3OutboundUK"}' \
  | jq -r .data.id)

# 2. Log the call you just made from your mobile
curl -s -X POST $API/calls -H "$AUTH" -H "$JSON" \
  -d "{\"leadId\":\"$LEAD\",\"outcome\":\"connected\",\"notes\":\"Wants a proposal.\"}"

# 3. Note what was said, and move the status
curl -s -X POST $API/leads/$LEAD/notes -H "$AUTH" -H "$JSON" \
  -d '{"text":"Sending pricing for 240 vehicles on Thursday."}'
curl -s -X PATCH $API/leads/$LEAD/status -H "$AUTH" -H "$JSON" \
  -d '{"status":"qualified"}'

# 4. Set the follow-up
curl -s -X POST $API/tasks -H "$AUTH" -H "$JSON" \
  -d "{\"label\":\"Send the pricing sheet\",\"leadId\":\"$LEAD\",\"dueAt\":\"2026-08-21T09:00:00Z\"}"

# 5. Read it all back
curl -s "$API/leads/$LEAD/timeline" -H "$AUTH" | jq '.data.items[] | {kind, timestamp}'
```

Every one of those actions shows up on the timeline, attributed to the member the key belongs to.

## Rate limits

No per-key rate limit is enforced today, on writes or reads. One is coming, with headers and notice — see [Rate limits](/guides/rate-limits) for what to expect and how to build for it now.
