# Pitch the Plug API

Base URL: https://app.pitchtheplug.com/v1
OpenAPI: https://app.pitchtheplug.com/v1/openapi.json

## Quickstart

Send a brand. Get back the people who run influencer marketing there: name, title, verified email, LinkedIn, location, plus the brand's own Instagram and TikTok.

**curl**

```bash
curl -s https://app.pitchtheplug.com/v1/contacts \
  -H "Authorization: Bearer $PTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"brand": "Glossier"}'
```

**Node**

```js
const BASE = "https://app.pitchtheplug.com/v1";
const headers = {
  Authorization: `Bearer ${process.env.PTP_API_KEY}`,
  "Content-Type": "application/json",
};

export async function findPlug(brand) {
  let res = await fetch(`${BASE}/contacts`, {
    method: "POST",
    headers,
    body: JSON.stringify({ brand }),
  });
  let data = await res.json();
  if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);

  // New brand: we are searching live. Check back until it lands (about a minute).
  while (data.status === "running") {
    await new Promise((r) => setTimeout(r, data.retry_after * 1000));
    res = await fetch(`${BASE}/searches/${data.id}`, { headers });
    data = await res.json();
    if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
  }
  return data; // status is "done" or "failed"
}

const result = await findPlug("Glossier");
console.log(result.contacts.filter((c) => c.email));
```

**Python**

```python
import os, time, requests

BASE = "https://app.pitchtheplug.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['PTP_API_KEY']}"}

def find_plug(brand):
    res = requests.post(f"{BASE}/contacts", headers=HEADERS, json={"brand": brand})
    data = res.json()
    res.raise_for_status()

    # New brand: we are searching live. Check back until it lands (about a minute).
    while data["status"] == "running":
        time.sleep(data["retry_after"])
        res = requests.get(f"{BASE}/searches/{data['id']}", headers=HEADERS)
        data = res.json()
        res.raise_for_status()
    return data  # status is "done" or "failed"

result = find_plug("Glossier")
print([c for c in result["contacts"] if c["email"]])
```

Two speeds. A brand someone searched in the last 30 days answers right away with `200`. A brand we have not seen answers `202` while we search live, which takes about a minute. The Node and Python examples above handle both.

## Auth

Base URL: `https://app.pitchtheplug.com/v1`. Every request needs your key in the `Authorization` header.

```http
Authorization: Bearer ptp_live_your_key_here
```

Your key is shown to you once. Keep it in an environment variable, not in code. If it leaks, email [neilmagnuson11@gmail.com](mailto:neilmagnuson11@gmail.com) and we will kill it and send a new one.

## Find contacts

```http
POST /v1/contacts
```

| Field | Type | What it is |
| --- | --- | --- |
| `brand` | string | Brand name, like `Glossier`. Use the name people actually say. Required unless you send `domain`. |
| `domain` | string | The brand's website, like `glossier.com`. A full URL is fine. Send it with `brand` when you have both. |

**200: the contacts are ready.**

```json
{
  "id": "srch_8123",
  "status": "done",
  "brand": "Glossier",
  "domain": "glossier.com",
  "linkedin_company_url": "https://www.linkedin.com/company/glossier-inc-",
  "parent_company": null,
  "instagram": "https://www.instagram.com/glossier",
  "tiktok": "https://www.tiktok.com/@glossier",
  "cached": true,
  "charged": true,
  "searched_at": "2026-09-21T18:02:11.000Z",
  "contacts": [
    {
      "full_name": "Jordan Rivera",
      "first_name": "Jordan",
      "last_name": "Rivera",
      "title": "Senior Manager, Influencer Marketing",
      "headline": "Senior Manager, Influencer Marketing at Glossier | Beauty, creators, community",
      "company": "Glossier",
      "email": "jordan.rivera@glossier.com",
      "email_status": "verified",
      "email_source": "findymail",
      "linkedin_url": "https://www.linkedin.com/in/jordan-rivera-example",
      "location": "New York, New York, United States"
    }
  ],
  "usage": { "quota": 200, "used": 17, "remaining": 183 }
}
```

**202: new brand, live search started.** Wait `retry_after` seconds, then check the search.

```json
{
  "id": "srch_8124",
  "status": "running",
  "brand": "Crayola",
  "poll_url": "/v1/searches/srch_8124",
  "retry_after": 15,
  "usage": { "quota": 200, "used": 18, "remaining": 182 }
}
```

| Contact field | What it is |
| --- | --- |
| `full_name`, `first_name`, `last_name` | The person. |
| `title` | Their job title, cleaned up from their LinkedIn headline. |
| `headline` | The raw LinkedIn headline, exactly as they wrote it. |
| `company` | Where LinkedIn says they work. Can be the parent company for a sub-brand. |
| `email` | Work email, or `null` if we could not verify one. |
| `email_status` | `verified` or `not_found`. We never return guessed addresses. |
| `email_source` | Which verifier confirmed it. |
| `linkedin_url` | Their LinkedIn profile. Useful when there is no email. |
| `location` | Where they are based. Can be `null` for brands searched before Sep 2026. |

Contacts with an email come first. People are ranked for Influencer, Social, Partnerships and Brand Marketing roles, manager level and up. `parent_company` is set when the brand's marketing team sits at a parent (think a L'Oreal sub-brand). `cached` tells you if this came from a recent search.

## Check a search

```http
GET /v1/searches/{id}
```

Same response shape as above. `status` is `running`, `done` or `failed`. A `failed` search includes a `reason` and is never counted. When the reason is `no_emails`, `contacts` still lists the people we found, with their LinkedIn. Checking a search is free, and you can re-read a finished search any time with the same id. You can only read searches made with your own key.

| `reason` | What happened |
| --- | --- |
| `no_linkedin` | We could not pin down the company. Try the full brand name or send the `domain`. |
| `no_contacts` | We found the company but nobody in a marketing role. |
| `no_emails` | We found the right people but could not verify an email for any of them. |
| `timeout`, `scrape_error`, `error` | Our side. Try again in a few minutes. |

## Usage

```http
GET /v1/usage
```

```json
{ "quota": 200, "used": 17, "remaining": 183 }
```

Every search response carries the same `usage` object, so you rarely need to call this.

## What counts as a search

- A search counts when it comes back with **at least one verified email**. That is 1 search, no matter how many contacts are in it.
- A search that fails or finds no email is **not counted**. You will see `"charged": false`.
- Checking a search, re-reading an old one and `GET /v1/usage` are free.
- Searching the same brand again counts again. Save what you get.

## Errors

Errors are JSON with a stable `code` you can branch on.

```json
{ "error": { "code": "quota_exhausted", "message": "You have used all 200 searches on this key." } }
```

| HTTP | `code` | What to do |
| --- | --- | --- |
| 400 | `invalid_request` | Send JSON with a `brand` or a `domain`. |
| 401 | `unauthorized` | Missing, wrong or revoked key. |
| 402 | `quota_exhausted` | You are out of searches. Email us to add more. |
| 404 | `not_found` | No search with that id on your key. |
| 429 | `rate_limited` | Slow down. Wait for the `Retry-After` header. |
| 429 | `too_many_in_flight` | You already have 2 new-brand searches running. Let one finish. |
| 503 | `at_capacity` | We hit our daily ceiling for new brands. Recently searched brands still work. Not counted. |
| 503 | `api_disabled` | The API is paused. Try later. |
| 500 | `server_error` | Our side. Not counted. Try again. |

## Rate limits

- **6 searches a minute** and **60 searches a day** per key.
- **2 new-brand searches running at once** per key. Cached brands do not count toward this.
- 120 status checks a minute.

Need more room? Tell us what you are building: [neilmagnuson11@gmail.com](mailto:neilmagnuson11@gmail.com).

## Use it with Codex or Claude

Working with a coding agent? Put your key in `PTP_API_KEY`, then paste this in. It points the agent at a plain markdown copy of these docs ([docs.md](https://pitchtheplug.com/developers/docs.md)) and the [OpenAPI spec](https://app.pitchtheplug.com/v1/openapi.json).

```text
You can look up who runs influencer marketing at any brand with the Pitch the Plug API.

Docs (read these first): https://pitchtheplug.com/developers/docs.md
OpenAPI spec: https://app.pitchtheplug.com/v1/openapi.json
Auth: send the header "Authorization: Bearer $PTP_API_KEY". The key is in the PTP_API_KEY env var. Never print it or commit it.

To find contacts: POST https://app.pitchtheplug.com/v1/contacts with JSON {"brand": "<brand name>"}.
- HTTP 200 means the contacts are in the response.
- HTTP 202 means a live search is running. Wait "retry_after" seconds, then GET https://app.pitchtheplug.com/v1/searches/<id> and repeat until "status" is "done" or "failed". It takes about a minute.
Only contacts where "email_status" is "verified" have an email. Each search that returns at least one email uses 1 of my searches, so do not search the same brand twice. Save results to a file as you go. Run at most 2 new brands at a time and at most 6 searches a minute.
```

## Good to know

- Results for a brand are reused for 30 days, then searched fresh.
- Pitch like a person. Short, specific, one idea the brand can say yes to. These are real inboxes and reply rates drop fast when pitches read like a blast.
- Big holding companies, tiny DTC shops and brands outside the US are the hardest to get right. If a result looks off, send us the search id and we will look.
