# 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 handle both. Want one call that just waits? Send `"wait": true`.

## Auth

Base URL: `https://app.pitchtheplug.com/v1`. Every request carries your key as a bearer token.

**Headers**

| Field | Type | Required | What it is |
| --- | --- | --- | --- |
| `Authorization` | header | yes | `Bearer ptp_live_...` Your key. Every endpoint needs it except `/v1/openapi.json`. |
| `Content-Type` | header | yes | `application/json` on `POST` requests. |

```http
Authorization: Bearer ptp_live_your_key_here
```

Your key is shown to you once. Keep it in an environment variable on a server, never in a browser or a repo. 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 https://app.pitchtheplug.com/v1/contacts
```
Counts as 1 search, only if it returns a verified email.

### Request

A JSON body with the brand. That is the whole request.

**Body**

| Field | Type | Required | What it is |
| --- | --- | --- | --- |
| `brand` | string | yes | Brand name, like `Glossier`. Use the name people actually say. Required unless you send `domain`. |
| `domain` | string | no | The brand's website, like `glossier.com`. A full URL is fine. Send it with `brand` when you have both. |
| `wait` | boolean | no | Default `false`. Set `true` to hold the request open (up to 85 seconds) until a live search finishes, so you get the contacts from one call instead of checking back. |

```http
POST https://app.pitchtheplug.com/v1/contacts
Authorization: Bearer ptp_live_your_key_here
Content-Type: application/json

{
  "brand": "Glossier",
  "domain": "glossier.com",
  "wait": false
}
```

### Response

You get one of two answers. **`200`** means the contacts are in the body. **`202`** means a live search started: wait `retry_after` seconds, then check the search.

**200 · done**

```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 · running**

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

**Response fields**

| Field | Type | Required | What it is |
| --- | --- | --- | --- |
| `id` | string | no | Search id, like `srch_8123`. Use it to check the search or read it again later. |
| `status` | string | no | `running`, `done` or `failed`. |
| `brand` | string | no | The brand as we searched it. |
| `contacts` | array | no | The people. Contacts with a verified email come first. See the contact fields below. |
| `usage` | object | no | `quota`, `used` and `remaining` for your key, after this call. |
| `charged` | boolean | no | `true` if this search counted against your quota. Failed and empty searches are always `false`. |
| `cached` | boolean | no | `true` if this came from a search in the last 30 days, `false` if we searched live. |
| `searched_at` | string | no | ISO timestamp of this search. |
| `domain` | string | null | no | The brand's website domain. |
| `linkedin_company_url` | string | null | no | The LinkedIn company page we matched. |
| `parent_company` | string | null | no | Set when the brand's marketing team sits at a parent company (think a L'Oreal sub-brand). |
| `instagram` | string | null | no | The brand's own Instagram. |
| `tiktok` | string | null | no | The brand's own TikTok. |
| `poll_url` | string | no | Only while `running`. Where to check the search. |
| `retry_after` | integer | no | Only while `running`. Seconds to wait before checking. |
| `reason` | string | no | Only when `failed`. Why. See Check a search. |

**Contact fields**

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

People are ranked for Influencer, Social, Partnerships and Brand Marketing roles, manager level and up.

### One call, no checking back

With `"wait": true` a new brand comes back as a normal `200` once the search lands, usually in about a minute. If it runs past 85 seconds you get the `202` instead and check the search as usual, so keep that branch in your code. Set your HTTP client timeout to 100 seconds or more.

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

## Check a search

```http
GET https://app.pitchtheplug.com/v1/searches/{id}
```
Free.

### Request

No body. Put the `id` from Find contacts in the path. You can only read searches made with your own key, and you can re-read a finished search any time.

```bash
curl -s https://app.pitchtheplug.com/v1/searches/srch_8124 \
  -H "Authorization: Bearer $PTP_API_KEY"
```

### Response

Always `200` with the same shape as Find contacts. Look at `status`.

**running**

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

**done**

```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 }
}
```

**failed**

```json
{
  "id": "srch_8125",
  "status": "failed",
  "brand": "Some Tiny Brand",
  "reason": "no_emails",
  "cached": false,
  "charged": false,
  "searched_at": "2026-09-21T18:05:40.000Z",
  "contacts": [
    {
      "full_name": "Sam Lee",
      "title": "Brand Director",
      "email": null,
      "email_status": "not_found",
      "linkedin_url": "https://www.linkedin.com/in/sam-lee-example"
    }
  ],
  "usage": { "quota": 200, "used": 17, "remaining": 183 }
}
```

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.

| `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 https://app.pitchtheplug.com/v1/usage
```
Free.

### Request

```bash
curl -s https://app.pitchtheplug.com/v1/usage \
  -H "Authorization: Bearer $PTP_API_KEY"
```

### Response

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

Every search response carries the same `usage` object, so you rarely need to call this. Handy for an agent that should stop before it runs dry.

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

## Rate limits

Limits are per key.

- **6 / min** searches (`POST /v1/contacts`)
- **60 / day** searches, rolling 24 hours
- **2 at once** new-brand searches running
- **120 / min** status and usage checks

| When you hit it | You get | What to do |
| --- | --- | --- |
| Per-minute or daily limit | `429` `rate_limited` with a `Retry-After` header (seconds) | Wait that long, then retry. |
| 2 new brands already running | `429` `too_many_in_flight` | Let one finish. Cached brands do not count toward this. |
| Our daily ceiling for new brands | `503` `at_capacity`, not counted | Recently searched brands still work. Try new ones tomorrow. |

Search responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers showing what is left of your daily limit, so you can pace yourself before you hit a wall. A `429` tells you which limit you hit and how long to wait.

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 38
RateLimit-Limit: 6
RateLimit-Remaining: 0
RateLimit-Reset: 38

{ "error": { "code": "rate_limited", "message": "Too many searches per minute. Check the Retry-After header and slow down." } }
```

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

## Errors

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

```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. |
| 503 | `api_disabled` | The API is paused. Try later. |
| 500 | `server_error` | Our side. Try again. |

## 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.
- Add "wait": true to the JSON to have the call hold (up to 85 seconds, so use a 100 second client timeout) and return the finished contacts in one response.
- HTTP 202 means a live search is still 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.
