Pitch the Plug API
Give us a brand. Get the people who run influencer marketing there, with verified emails. One endpoint, plain 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 -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"]])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.
Bearer ptp_live_... Your key. Every endpoint needs it except /v1/openapi.json.application/json on POST requests.Authorization: Bearer ptp_live_your_key_hereYour 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
Request
A JSON body with the brand. That is the whole request.
Glossier. Use the name people actually say. Required unless you send domain.glossier.com. A full URL is fine. Send it with brand when you have both.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.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 }
}srch_8123. Use it to check the search or read it again later.running, done or failed.quota, used and remaining for your key, after this call.true if this search counted against your quota. Failed and empty searches are always false.true if this came from a search in the last 30 days, false if we searched live.running. Where to check the search.running. Seconds to wait before checking.failed. Why. See Check a search.first_name and last_name come separately too.null if we could not verify one.verified or not_found. We never return guessed addresses.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 -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
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 -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.
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
Request
curl -s https://app.pitchtheplug.com/v1/usage \
-H "Authorization: Bearer $PTP_API_KEY"Response
{ "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/usageare free. - Searching the same brand again counts again. Save what you get.
Rate limits
Limits are per key.
POST /v1/contacts)| 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/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": { "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) and the OpenAPI spec.
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.
Need a key, or stuck?
Tell us what you are building. We reply within a day.