v1 · early access

Pitch the Plug API

Give us a brand. Get the people who run influencer marketing there, with verified emails. One endpoint, plain JSON.

Base URLhttps://app.pitchtheplug.com/v1

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 -s https://app.pitchtheplug.com/v1/contacts \
  -H "Authorization: Bearer $PTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"brand": "Glossier"}'
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));
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
AuthorizationrequiredheaderBearer ptp_live_... Your key. Every endpoint needs it except /v1/openapi.json.
Content-Typerequiredheaderapplication/json on POST requests.
header
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 [email protected] and we will kill it and send a new one.

Find contacts

POSThttps://app.pitchtheplug.com/v1/contactsCounts as 1 search, only if it returns a verified email

Request

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

Body
brandrequiredstringBrand name, like Glossier. Use the name people actually say. Required unless you send domain.
domainstringThe brand's website, like glossier.com. A full URL is fine. Send it with brand when you have both.
waitbooleanDefault 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.
request
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.

{
  "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": "[email protected]",
      "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 }
}
{
  "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
idstringSearch id, like srch_8123. Use it to check the search or read it again later.
statusstringrunning, done or failed.
brandstringThe brand as we searched it.
contactsarrayThe people. Contacts with a verified email come first. See the contact fields below.
usageobjectquota, used and remaining for your key, after this call.
chargedbooleantrue if this search counted against your quota. Failed and empty searches are always false.
cachedbooleantrue if this came from a search in the last 30 days, false if we searched live.
searched_atstringISO timestamp of this search.
domainstring | nullThe brand's website domain.
linkedin_company_urlstring | nullThe LinkedIn company page we matched.
parent_companystring | nullSet when the brand's marketing team sits at a parent company (think a L'Oreal sub-brand).
instagramstring | nullThe brand's own Instagram.
tiktokstring | nullThe brand's own TikTok.
poll_urlstringOnly while running. Where to check the search.
retry_afterintegerOnly while running. Seconds to wait before checking.
reasonstringOnly when failed. Why. See Check a search.
Contact fields
full_namestringThe person. first_name and last_name come separately too.
titlestringTheir job title, cleaned up from their LinkedIn headline.
headlinestringThe raw LinkedIn headline, exactly as they wrote it.
companystringWhere LinkedIn says they work. Can be the parent company for a sub-brand.
emailstring | nullWork email, or null if we could not verify one.
email_statusstringverified or not_found. We never return guessed addresses.
email_sourcestring | nullWhich verifier confirmed the email.
linkedin_urlstring | nullTheir LinkedIn profile. Useful when there is no email.
locationstring | nullWhere 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.

curl
curl -s https://app.pitchtheplug.com/v1/contacts \
  -H "Authorization: Bearer $PTP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"brand": "Crayola", "wait": true}'
GEThttps://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.

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

{
  "id": "srch_8124",
  "status": "running",
  "brand": "Crayola",
  "poll_url": "/v1/searches/srch_8124",
  "retry_after": 15,
  "usage": { "quota": 200, "used": 18, "remaining": 182 }
}
{
  "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": "[email protected]",
      "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 }
}
{
  "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.

reasonWhat happened
no_linkedinWe could not pin down the company. Try the full brand name or send the domain.
no_contactsWe found the company but nobody in a marketing role.
no_emailsWe found the right people but could not verify an email for any of them.
timeout, scrape_error, errorOur side. Try again in a few minutes.

Usage

GEThttps://app.pitchtheplug.com/v1/usageFree

Request

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

Response

200
{ "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

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 itYou getWhat to do
Per-minute or daily limit429 rate_limited with a Retry-After header (seconds)Wait that long, then retry.
2 new brands already running429 too_many_in_flightLet one finish. Cached brands do not count toward this.
Our daily ceiling for new brands503 at_capacity, not countedRecently 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.

429
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: [email protected].

Errors

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

error
{ "error": { "code": "quota_exhausted", "message": "You have used all 200 searches on this key." } }
HTTPcodeWhat to do
400invalid_requestSend JSON with a brand or a domain.
401unauthorizedMissing, wrong or revoked key.
402quota_exhaustedYou are out of searches. Email us to add more.
404not_foundNo search with that id on your key.
429rate_limitedSlow down. Wait for the Retry-After header.
429too_many_in_flightYou already have 2 new-brand searches running. Let one finish.
503at_capacityWe hit our daily ceiling for new brands. Recently searched brands still work.
503api_disabledThe API is paused. Try later.
500server_errorOur 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) and the OpenAPI spec.

prompt
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

Need a key, or stuck?

Tell us what you are building. We reply within a day.

Request access