Cold Email Personalizer docs
Cold Email Personalizer researches one lead from public sources and writes a first cold email or opening lines. Every claim in the output points to the evidence behind it. You can call it over a REST API or from an MCP client such as Claude Code.
Quick start
- Sign in with Google at https://coldemailpersonalizer.com/sign-in. Signing in creates your workspace.
- Open the API keys page and create a key. The secret starts with
cep_live_and is shown once. - Add funds from the Billing page. Each run holds its price from your balance while it works.
- Send your first request.
A run is charged only when it returns a validated email or opening lines. The current price of each operation is at GET /v1/prices, described under Prices and charging.
Send a request
Pick a language. The HTTP tab is the raw request, for any client that can send one.
curl https://coldemailpersonalizer.com/v1/personalize \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"lead": {
"name": "Jordan Lee",
"company_domain": "example.com"
},
"offer": {
"what_you_sell": "Warehouse picking software",
"who_it_helps": "Operations leads at multi-site 3PLs"
},
"output_format": "full_email"
}'
# → the run (status "completed") or, after the wait window, { "run_id": "...", "status": "queued" }The request waits for the run by default. If the run finishes in time, the response is the whole run. If it does not, the response is 202 with { "run_id", "status" }.
Check the run
# Check the run until status is "completed"
curl https://coldemailpersonalizer.com/v1/runs/$RUN_ID \
-H "Authorization: Bearer $API_KEY"A run is done when its status is completed or failed.
Authentication
The API has two ways in.
| Door | How | Used by |
|---|---|---|
| API key | Header Authorization: Bearer cep_live_... |
Your code, automation tools and headless MCP clients |
| Session | The sign-in cookie of the dashboard | The dashboard itself |
Every endpoint marked "API key or session" accepts either. Endpoints marked "Session" only accept a signed-in dashboard session. An API key belongs to one workspace, and everything it creates is billed to that workspace.
MCP clients that can open a browser sign in with OAuth instead of a key. See MCP.
Manage API keys
These endpoints back the API keys page. They need a session.
| Method | Path | Body | Response |
|---|---|---|---|
GET |
/v1/keys |
none | { keys } |
POST |
/v1/keys |
label, 1 to 60 characters |
201 with id, label, prefix, secret, created_at |
PATCH |
/v1/keys/:id |
label, 1 to 60 characters |
The key |
DELETE |
/v1/keys/:id |
none | The key, now revoked |
A key in the list has id, label, prefix, status (active or revoked), created_at, last_used_at and revoked_at. The secret appears only in the POST response. Renaming a revoked key answers 409 with key_revoked. A revoked key is refused at once with 401 and invalid_key.
Personalize
POST /v1/personalize
Research one lead and write an email or opening lines. Auth: API key or session.
The body has a lead and exactly one of offer or campaign_id.
| Field | Type | Notes |
|---|---|---|
lead |
object | Required. See the lead fields below. |
offer |
object | Your offer. Required unless you send campaign_id. |
campaign_id |
UUID | A saved campaign to use as the offer. Required unless you send offer. |
output_format |
full_email or opening_lines |
Optional. Overrides the format of the offer or campaign. |
Lead fields. Each is a string of at most 2,000 characters. Send linkedin_url, or email, or both name and company_domain.
| Field | Notes |
|---|---|
linkedin_url |
The lead's LinkedIn profile URL. |
email |
The lead's work email. |
name |
The lead's full name. Needs company_domain. |
company_domain |
The lead's company domain, such as example.com. |
signal |
Something that prompted the outreach. |
notes |
Anything else you know about the lead. |
prior_contact |
Any earlier contact with the lead. |
Offer fields. Text fields are strings of at most 2,000 characters. List fields hold at most 10 items of at most 500 characters each.
| Field | Type | Notes |
|---|---|---|
what_you_sell |
text | Required. |
who_it_helps |
text | Required. |
url |
text | Your website. |
name |
text | Your company or product name. |
core_value_proposition |
text | |
key_differentiator |
text | |
customer_outcomes |
list | Results your customers get. |
proof_points |
list | Customers, numbers or case studies. |
objections |
list | |
reference_customers |
list | |
alternatives |
list | |
integrations |
list | |
tone |
text | |
length |
short, medium or long |
|
primary_goal |
start conversation, book meeting, ask intro or ask feedback |
|
language |
two letters | Such as en. |
output_format |
full_email or opening_lines |
Default full_email. |
example_email |
text | An email in your voice, at most 4,000 characters. |
The output only uses facts about your offer that you provide. Fill in proof points and outcomes rather than leaving them out.
Waiting and the 202 response
The query parameter wait is true by default. With wait=true the request waits for the run, up to about 50 seconds. If the run finishes in that time, the response is 200 with the whole run. If it does not, or with wait=false, the response is 202:
{ "run_id": "...", "status": "queued" }
Poll GET /v1/runs/:id until status is completed or failed.
Retries with x-external-request-id
Send the header x-external-request-id with your own id for the request, at most 255 characters. It makes retries safe.
- A retry with the same id and the same body returns the existing run. It does not start or charge a second run.
- A retry with the same id and a different body answers
409withidempotency_conflict. - A retry after the campaign it used was edited counts as a different body, so it also answers
409withidempotency_conflict.
Status codes
| Status | Meaning |
|---|---|
200 |
The run finished. The body is the run. |
202 |
The run was accepted and is still working. The body is { run_id, status }. |
400 |
invalid_request. The body failed validation, or x-external-request-id is longer than 255 characters. |
401 |
No valid API key or session. |
402 |
insufficient_balance. See Errors. |
404 |
not_found. The campaign_id does not exist in this workspace. |
409 |
idempotency_conflict, or campaign_deleted when the campaign was deleted. |
429 |
rate_limited. A rate limit was reached. See Rate limits. |
503 |
billing_unavailable or rate_limit_unavailable. Try again shortly. |
Runs
GET /v1/runs
List runs, newest first. Auth: API key or session.
| Query | Notes |
|---|---|
status |
queued, running, completed or failed. |
outcome |
ok, no_evidence, no_relevant_angle or identity_unresolved. |
q |
Searches the lead's name, company domain, email and LinkedIn URL. 1 to 200 characters. |
campaign_id |
Runs that used this campaign. |
since |
ISO 8601 date and time with an offset, such as 2026-10-01T00:00:00Z. |
until |
ISO 8601 date and time with an offset. |
page |
Default 1. |
page_size |
1 to 100. Default 20. |
The response is { runs, page, page_size, total }. Each item in runs is a run, as below.
GET /v1/runs/:id
Get one run. Auth: API key or session. A run that is not in your workspace answers 404 with not_found.
| Field | Notes |
|---|---|
run_id |
The run's id. |
status |
queued, running, completed or failed. |
outcome |
For a completed run: ok, no_evidence, no_relevant_angle or identity_unresolved. Otherwise null. |
stage |
While the run works: resolve_identity, research, draft or validate. Otherwise null. |
charged_usd |
What the run cost, once it is settled. 0 when it was not charged. null before settling. |
duration_ms |
How long the run took. null until the run finishes. |
created_at, finished_at |
ISO 8601 times. finished_at is null until the run ends. |
request |
What you sent: lead, offer, output_format, external_request_id and campaign_id. |
output |
{ subject, body } for an email, { lines } for opening lines, or null. |
evidence |
The sources found. Each has id, kind, claim, source_url, excerpt, provider (always null; the data supplier is not disclosed), field, published_at, retrieved_at and tool_call. |
claims |
Each claim in the output, as { text, evidence_ids }. |
angles |
Angles considered, as { angle, why_relevant, evidence_ids, used }. |
identity |
Who the lead resolved to: name, title, company, company_domain and confidence from 0 to 1. |
reason |
Why the run ended the way it did. |
error |
For a failed run, { code, message }. Otherwise null. |
rerun_of |
The run this one reruns, or null. |
campaign_id |
The campaign used, or null. |
Evidence kind is public for a public web source, provider for an enrichment record, user for something you sent, or inference for a conclusion drawn from other evidence.
Evidence is attributed to the resolved person or company before it is shown. Facts about someone else with the same name are dropped and never cited.
POST /v1/runs/:id/rerun
Run the same request again as a new run. Auth: API key or session. It takes the same wait parameter and answers like POST /v1/personalize. The new run has rerun_of set to the original. A run that is still queued or running answers 409 with not_rerunnable.
Campaigns
A campaign is a saved offer. Send its id as campaign_id instead of an offer. A run copies the campaign when it starts, so later edits never change a run that already exists.
All campaign endpoints take an API key or a session.
| Method | Path | Response |
|---|---|---|
GET |
/v1/campaigns |
{ campaigns } |
POST |
/v1/campaigns |
201 with the campaign |
GET |
/v1/campaigns/:id |
The campaign |
PATCH |
/v1/campaigns/:id |
The campaign |
POST |
/v1/campaigns/:id/duplicate |
201 with the copy, named with (copy) at the end |
DELETE |
/v1/campaigns/:id |
{ "deleted": true } |
| Field | Notes |
|---|---|
name |
Required. 1 to 80 characters. |
what_you_sell |
Required. At most 2,000 characters. |
who_it_helps |
Required. At most 2,000 characters. |
website |
At most 2,000 characters. |
proof_points |
One item per line. At most 2,000 characters in total. See the line rule below. |
customer_outcomes |
One item per line. At most 2,000 characters in total. See the line rule below. |
call_to_action |
At most 484 characters. |
tone |
direct, friendly, conversational or formal. Default direct. |
goal |
book_call, get_reply or share_resource. Default book_call. |
language |
Two lowercase letters. Default en. |
length |
very_short, short or medium. Default short. |
output_format |
email or opening_lines. Default email. |
example_email |
At most 4,000 characters. |
Responses add id, runs_count, last_used_at, created_at and updated_at.
The line rule: proof_points and customer_outcomes become one item per non-empty line. Only the first 10 lines are used, and each line can be at most 500 characters. The call_to_action takes one of the 10 proof point slots. A campaign that could not run is refused with 400, invalid_request and details.
PATCH changes only the fields you send, and needs at least one. Deleting a campaign removes it from every list. A personalize request that names it afterwards answers 409 with campaign_deleted. A campaign that is not in your workspace answers 404 with not_found.
Refine a campaign from a website
POST /v1/campaigns/refine
Read a public website and draft campaign fields from it. Auth: API key or session.
The body is { "url": "https://example.com" }. The URL must be a public http or https address of at most 2,048 characters.
| Field | Notes |
|---|---|
fields |
Draft values for name, what_you_sell, who_it_helps, proof_points, customer_outcomes and call_to_action. A field the site does not cover is left out. name falls back to the site's hostname. |
questions |
Two or three questions whose answers would sharpen the offer. |
source_url |
The URL that was read. |
usable |
true when the site said what you sell and who it helps. |
reason |
Why the result is not usable, when usable is false. |
charged_usd |
What the call cost. 0 when the result is released. null only when settlement is unknown. |
settlement |
settled when charged, released when not charged, or unknown when billing did not confirm. |
Review the drafted fields before you save them as a campaign.
| Status | Meaning |
|---|---|
200 |
The site was read. Check usable. |
400 |
invalid_request. The URL is not a public http or https address. |
402 |
insufficient_balance. |
502 |
upstream_error. The site could not be read. You are not charged. |
503 |
unavailable when refining from a website is switched off, or billing_unavailable. Try again shortly. |
Prices and charging
GET /v1/prices
The current prices. It needs no API key or session.
| Field | Notes |
|---|---|
personalization_usd |
One run of POST /v1/personalize. Default $0.50. |
research_usd |
Research on its own. Default $0.10. No endpoint charges it today. |
refinement_usd |
One call of POST /v1/campaigns/refine. Free by default (0). |
charged_when |
One sentence per operation saying when it is charged. |
The charging rules:
- A personalization is charged only when the run completes with outcome
ok. - Every other outcome and every failed run is not charged.
- A refinement is free by default. When a price is set, it is charged only when the result is usable.
- When a run is accepted, its price is held from your balance. The hold becomes the charge, or it is released.
GET /v1/balance
Your workspace balance. Auth: API key or session.
| Field | Notes |
|---|---|
available_usd |
What you can spend now. |
reserved_usd |
What is held for runs in progress. |
reserved_runs |
How many runs hold money. |
price_usd |
The price of one personalization. |
When the balance cannot be read, the answer is 503 with balance_unavailable. It is never shown as zero.
GET /v1/usage
Runs and spending per day. Auth: API key or session.
The query period is 7d, 30d or month. The default is 30d, and month means the current calendar month in UTC.
The response has period, from, to, days and totals. Each day has date and the counts ok, no_evidence, no_relevant_angle, identity_unresolved and failed. The totals are completed, ok, failed and spent_usd.
Outcomes
A completed run has one of four outcomes.
| Outcome | Meaning | Charged |
|---|---|---|
ok |
An email or opening lines were written, and every claim is backed by evidence. | Yes |
no_evidence |
Nothing usable was found about the lead. | No |
no_relevant_angle |
Evidence exists, but none of it connects to your offer. | No |
identity_unresolved |
The lead could not be pinned to one person. | No |
Only ok has an output. The other outcomes explain themselves in reason.
Failed runs
A run with status failed has an error with a code and a message. A failed run is never charged.
| Code | Meaning |
|---|---|
invalid_input |
The request could not be run. |
upstream_error |
A research source failed. A rerun may help. |
upstream_balance |
A research source refused the request. |
model_error |
The writing model failed. |
validation_failed |
The draft could not be made to match the evidence. |
timeout |
The run took too long. A rerun may help. |
budget_exhausted |
The run reached its research or model spending cap. |
A few other codes, such as internal and stale, mean the service itself failed the run.
Errors
Every error has the same shape:
{ "error": { "code": "not_found", "message": "Run not found" } }
| Status | Code | Meaning |
|---|---|---|
400 |
invalid_request |
The request is invalid. When the body or query fails validation, the error also carries details, a list of the problems. Other invalid requests, such as a body that is not JSON, have no details. |
401 |
invalid_key |
The API key is invalid or revoked. |
401 |
unauthenticated |
A session endpoint was called without signing in. |
401 |
no_workspace |
The session has no workspace. |
403 |
no_workspace |
On the MCP server: you are no longer a member of the workspace the client was connected to. |
| varies | auth_error |
Sign-in or OAuth refused the request. The status comes from the sign-in service. |
402 |
insufficient_balance |
The balance does not cover the price. |
404 |
not_found |
The run, campaign, key or client does not exist in your workspace. |
409 |
idempotency_conflict |
The x-external-request-id was already used with a different body. |
409 |
not_rerunnable |
The run is still in progress. |
409 |
campaign_deleted |
The campaign was deleted. |
409 |
key_revoked |
The API key is revoked and cannot be renamed. |
429 |
rate_limited |
A rate limit was reached; the body carries retry_after_s and the response a Retry-After header. See Rate limits. |
502 |
upstream_error |
A website could not be read during a refinement. |
503 |
rate_limit_unavailable |
The rate limiter cannot be reached right now. Try again shortly. |
503 |
billing_unavailable |
Billing is unavailable. Try again shortly. |
503 |
balance_unavailable |
The balance cannot be read right now. |
503 |
unavailable |
Refining from a website is unavailable. |
500 |
internal |
Something failed on our side. |
The 402 body has extra fields at the top level:
{
"error": { "code": "insufficient_balance", "message": "Add money to run personalizations" },
"balance_usd": 0.2,
"price_usd": 0.5,
"top_up_url": "https://coldemailpersonalizer.com/billing"
}
balance_usd is your available balance, price_usd is the price of the operation, and top_up_url is the Billing page. Add funds there before you retry.
MCP
The MCP server lets an assistant such as Claude Code call the service directly. It is a streamable HTTP server. Its URL is on the MCP page of the dashboard.
Install
Claude Code:
claude mcp add --transport http cold-email-personalizer <server url from the MCP page>
- Run this command in your terminal.
- In Claude Code, run
/mcp, choosecold-email-personalizer, then Authenticate. - Your browser opens. Sign in and allow access.
Claude Desktop:
- Open Settings, then Connectors, and choose Add custom connector.
- Name it Cold Email Personalizer and paste the server URL from the MCP page.
- Choose Connect. Your browser opens to sign in and allow access.
Cursor, in .cursor/mcp.json in your project or in the global MCP settings:
{
"mcpServers": {
"cold-email-personalizer": {
"url": "<server url from the MCP page>"
}
}
}
Then open Cursor Settings, then MCP, and choose Connect next to cold-email-personalizer. Your browser opens to sign in and allow access.
Other clients: if your client supports OAuth, add the server URL and connect. Headless clients that cannot open a browser send an API key in the Authorization header instead:
{
"mcpServers": {
"cold-email-personalizer": {
"url": "<server url from the MCP page>",
"headers": { "Authorization": "Bearer $API_KEY" }
}
}
}
Tools
| Tool | Scope | What it does | Charged |
|---|---|---|---|
personalize |
runs:write |
Researches a lead and writes an email or opening lines. | Only on outcome ok |
get_run |
runs:read |
Gets one run with its output, evidence and claims. | No |
list_runs |
runs:read |
Lists runs, newest first, with filters and paging. | No |
list_campaigns |
campaigns:read |
Lists saved campaigns. | No |
get_campaign |
campaigns:read |
Gets one saved campaign. | No |
refine_offer |
runs:write |
Reads a public website and drafts offer fields and questions. | Only on a usable result |
The inputs match the REST bodies and queries. personalize also takes wait, which defaults to true, and external_request_id, which works like the x-external-request-id header. get_run takes run_id, get_campaign takes campaign_id, and refine_offer takes url.
A tool error comes back as { code, message }. The codes are insufficient_scope when the connection was not granted the tool's scope, insufficient_balance with a top_up_url, not_found, internal, and the REST codes.
Scopes
When you connect a client, you allow it these scopes for your active workspace:
| Scope | Allows |
|---|---|
runs:write |
Creating personalizations. Each run is charged from your balance. |
runs:read |
Reading runs, outputs and evidence. |
campaigns:read |
Reading your saved campaigns. |
Clients may also ask for offline_access, which lets them refresh their access without signing in again. An access token lasts 900 seconds. An API key grants every scope.
Connected clients and revoking
Revoke a client from the MCP page of the dashboard. Its access stops on its next request, and it can no longer refresh. A revoked client is refused with HTTP 401 and the code revoked.
| Method | Path | Auth | Response |
|---|---|---|---|
GET |
/v1/mcp/clients |
Session | { clients }, each with id, name, scopes, status (connected or revoked), connected_at and last_used_at |
DELETE |
/v1/mcp/clients/:clientId |
Session | { "status": "revoked" }, or 404 with not_found |
GET |
/v1/mcp/install |
Session | { server_url, snippets } with the install steps above |
Paste into your assistant
Add this to your assistant's preferences:
When I give you a lead and an offer, use the cold-email-personalizer tools to write the first email. Show me the email and the evidence behind each claim. Never send it, never make up details about my offer, and ask me before running more than a few leads because each run costs money.
Limits
| Limit | Value |
|---|---|
| Lead and offer text fields | 2,000 characters each |
| Offer list fields | 10 items of at most 500 characters each |
example_email |
4,000 characters |
Offer language |
2 characters |
Campaign name |
80 characters |
Campaign proof_points and customer_outcomes |
2,000 characters in total, and 10 lines of at most 500 characters each |
Campaign call_to_action |
484 characters |
x-external-request-id and external_request_id |
255 characters |
Refine url |
2,048 characters |
GET /v1/runs page_size |
100 |
GET /v1/runs search q |
200 characters |
API key label |
60 characters |
| OAuth access token | 900 seconds |
Rate limits
Requests are limited per IP address, per workspace and per account. Defaults; your deployment may differ:
| Limit | Default |
|---|---|
| All requests per IP | 300 per minute |
| Read requests per IP | 120 per minute |
| Sign-in attempts per IP | 10 per minute |
| Sign-in attempts per email | 5 per minute |
| Account creations per IP | 3 per minute |
| Run submissions per workspace | 30 per minute |
| Runs in progress per workspace | 10 |
| Website refinements per workspace | 10 per day (UTC) |
| Changes per workspace | 60 per minute |
| Read requests per workspace | 600 per minute |
A refused request answers 429 with a Retry-After header (seconds) and this body:
{
"error": { "code": "rate_limited", "message": "Run submission limit reached (30 per minute). Try again in 12 seconds.", "retry_after_s": 12 },
"message": "Run submission limit reached (30 per minute). Try again in 12 seconds."
}
Limits use fixed one-minute windows, so a burst can straddle a minute boundary and briefly reach about twice the limit. MCP tools return the same message together with retry_after_s. A refused request still counts toward its window.
Support
Email [email protected] for help.
Include the run_id and the error.code when you report a problem with a run.