Outreach API Reference
The Outreach API exposes the same engine that powers the in-app Cold Outreach surface — create leads, list campaigns, fetch replies, and track delivery, from any HTTP client. All endpoints are versioned under /api/v1/outreach/* and authenticated via a Bearer token on the Authorization header.
Quick start — generate an API key in Outreach → Integrations → API keys (Pro plan or higher). Then:
curl https://www.planmysaas.com/api/v1/outreach/me \
-H "Authorization: Bearer pms_live_<your-key>"Returns the workspace + the scopes attached to the key. If you see 401 Unauthorized, the key is invalid or revoked. If you see 403 Forbidden with a scope error, the key is valid but missing the scope the endpoint needs.
Authentication
Every /api/v1/outreach/* request must carry anAuthorization: Bearer <key> header. Keys are workspace-scoped — all data returned belongs to the workspace that owns the key.
- Where to get a key — the API keys panel inside Outreach → Integrations. Plain-text key is shown once on creation and never again.
- Storage — server stores a SHA-256 hash; the key itself never appears in any list or get endpoint.
- Rotation — revoke the old key and generate a new one. We keep the revoked-key row so you can audit any later request that arrives with the leaked secret (it gets a clear “Key revoked” error instead of a generic 401).
Scopes
Each key carries a list of scopes. Endpoints assert the relevant scope; missing scope → 403. Pick the smallest set the integration needs — least-privilege limits blast radius if a key leaks.
| Scope | Grants |
|---|---|
leads:read | List + get leads, fetch sends/replies attached to a lead |
leads:write | Create + update leads, queue them into a campaign |
campaigns:read | List campaigns and their step counts + status |
campaigns:write | Create + edit campaigns and sequence steps (reserved — not yet wired) |
replies:read | List inbound replies + their AI classification |
sends:read | List outbound sends + delivery state (reserved) |
sends:trigger | Force-send the next step on a lead (reserved — not yet wired) |
Endpoints
GET /v1/outreach/me
No scope required. Returns the workspace owning this key + the scopes attached. Use this as a smoke-test for new integrations.
curl https://www.planmysaas.com/api/v1/outreach/me \
-H "Authorization: Bearer pms_live_..."{
"workspace": { "id": "ws_abc", "name": "Acme Inc", "createdAt": "2025-09-01T..." },
"key": { "prefix": "pms_live_", "scopes": ["leads:read","leads:write","campaigns:read"] }
}GET /v1/outreach/campaigns
Scope: campaigns:read. Lists campaigns the workspace owns. Returns the idyou’ll pass toPOST /v1/outreach/leads as campaignId.
curl https://www.planmysaas.com/api/v1/outreach/campaigns \
-H "Authorization: Bearer pms_live_..."{
"data": [
{
"id": "camp_abc",
"name": "Vibe coders Q2",
"status": "active",
"fromName": "Anita",
"pitchContext": "We help solo SaaS founders ship in 7 days...",
"dailyCap": 80,
"_count": { "leads": 124, "sends": 312, "steps": 4, "replies": 27 },
"createdAt": "2026-04-01T...",
"updatedAt": "2026-04-26T..."
}
]
}GET /v1/outreach/leads
Scope: leads:read. Lists leads scoped to the workspace. Filter by campaignId, status,email, or paginate with cursor.
curl "https://www.planmysaas.com/api/v1/outreach/leads?campaignId=camp_abc&status=replied&limit=50" \
-H "Authorization: Bearer pms_live_..."POST /v1/outreach/leads
Scope: leads:write. Creates a lead inside an existing campaign. The lead enters the sequence at step 1 and starts firing on the next scheduler tick (within ~10 minutes).
curl -X POST https://www.planmysaas.com/api/v1/outreach/leads \
-H "Authorization: Bearer pms_live_..." \
-H "Content-Type: application/json" \
-d '{
"campaignId": "camp_abc",
"email": "founder@example.com",
"firstName": "Sam",
"lastName": "Altman",
"company": "Example Inc",
"role": "Founder",
"linkedinUrl":"https://linkedin.com/in/sam"
}'Idempotency: the unique constraint on (campaignId, email)means re-posting the same lead returns the existing row, not a duplicate. Safe to retry on transient network errors.
GET /v1/outreach/leads/[id]
Scope: leads:read. Returns one lead with its sends, replies, and step position.
GET /v1/outreach/replies
Scope: replies:read. Lists inbound replies with the AI classification (interested, meeting_request,objection, not_interested,unsubscribe_request, out_of_office,auto_reply, wrong_person,referral, question, other).
curl "https://www.planmysaas.com/api/v1/outreach/replies?campaignId=camp_abc&needsAction=true" \
-H "Authorization: Bearer pms_live_..."Webhooks
For event-driven integrations, configure webhook URLs in Outreach → Integrations → Webhooks. Each event POSTs a JSON envelope with an HMAC-SHA256 signature so your endpoint can verify it came from us.
Events
| Event | Fires when |
|---|---|
lead.created | A lead is created (UI, API, or auto-discovery) |
lead.replied | An inbound reply is matched to this lead |
lead.bounced | SMTP returns a hard-bounce on a send to this lead |
lead.unsubscribed | Lead unsubscribes (footer link or AI-classified opt-out) |
lead.completed | Lead reaches the end of the sequence |
send.sent | An outbound email left the SMTP server cleanly |
send.failed | SMTP returned an error after retries |
reply.received | An inbound reply landed (matched or unmatched) |
reply.classified | AI classifier finished tagging an inbound reply |
channel_touch.queued | A LinkedIn / Twitter touch was generated and queued for manual paste |
channel_touch.sent | The founder marked a queued touch as sent |
Signature verification
Every delivery includes these headers:
X-PMS-Signature—sha256=<hex>of the raw body, HMAC-keyed with the webhook secret you saved on creationX-PMS-Event— the event name (e.g.reply.received)X-PMS-Event-Id— unique id for this event delivery, used for idempotencyX-PMS-Attempt— 1 on first delivery, 2/3/4 on retries
// Node.js — verify the signature
import { createHmac } from "node:crypto"
export function verifyPmsWebhook(rawBody: string, headerSig: string, secret: string) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
// constant-time compare in production
return expected === headerSig
}Retries
Failed deliveries (network error or HTTP 5xx) retry on a backoff of 5 minutes → 30 minutes → 2 hours, then mark permanently failed. X-PMS-Event-Id stays constant across all retries so your endpoint can dedupe.
Rate limits
Per-workspace sliding-window caps protect against burst abuse and sustained leak. When you hit one, the response is 429 Too Many Requestswith a Retry-After header in seconds.
| Action | Per minute | Per hour | Per day |
|---|---|---|---|
| Discovery (ICP / campaign) | 3 | 15 | 50 |
| Lead enrichment | 30 | 200 | 1,000 |
| Reply draft | 10 | 60 | 200 |
| Channel-touch regenerate | 10 | 60 | 200 |
| Autopilot manual run | 2 | 10 | 50 |
Headers on a 429 response: Retry-After,X-RateLimit-Limit, X-RateLimit-Window(minute / hour / day),X-RateLimit-Remaining.
Error codes
| Status | Meaning | Action |
|---|---|---|
400 | Bad request — malformed JSON or missing required field | Fix the request body and retry |
401 | Unauthorized — missing / invalid / revoked Bearer token | Generate a fresh API key |
402 | Plan upgrade required — feature not available on current plan | Upgrade or use a different feature |
403 | Forbidden — key valid but missing the required scope | Recreate the key with the scope you need |
404 | Not found — resource doesn’t exist or belongs to another workspace | Verify the id; cross-workspace access is rejected here |
422 | Unprocessable — request was valid JSON but failed business rules | Read the error field for the specific reason |
429 | Rate limited — see Rate limits section | Wait Retry-After seconds and retry |
5xx | Server error — transient platform issue | Retry with exponential backoff (start at 1s, max 60s) |
Versioning
The current API is v1. We follow additive semantics — new fields and endpoints can land any time without bumping the version. Breaking changes (rename / remove a field, change response shape) get a v2 path with a 12-month overlap on v1. Subscribe to the changelog by configuring a webhook on system.api_change (reserved — not yet emitting).
Support
Stuck? File an issue or message us via the in-app help. Include your key prefix (NOT the full key), the request id from the response header X-Request-Id, and a curl command that reproduces. We respond within 1 business day on Pro+.