Connect your own systems
The Contact Form Outreach API lets your own software do what you do in the app: start campaigns, approve lists, send, and read results, contacts and replies. Webhooks tell your systems the moment something happens. Every account gets it at no extra cost.
$0.09 for each form that goes through. $10 covers about 111.
The same prices as the app, with no API fee.
What your software can do
- Everything the app does: campaigns, lists, sending, results and replies
- Webhooks the moment something happens
- 123 endpoints, JSON over HTTPS
- Keys made in the app, each with only the scopes you choose
- Same prices as the app, with no API fee
On this pageIntroduction
Introduction
Everything you can do in the app, your software can do through the API. A campaign goes the same way in both: you start it, it finds the companies, you review and approve its list, and then you send. Results, the emails and phone numbers collected, proof of each form that goes through and replies can all be read back.
- Every call goes to
https://api.contactformoutreach.com/v1, over HTTPS, with JSON in and out. - Times carry their zone, like
2026-10-09T10:30:00Z. Countries are two-letter codes likeDE, and languages two-letter codes likede. - Money is in whole US cents, in fields that end in
_cents. - Every answer carries a
request_id. Quote it if you write to us about a call.
Your first call
Ask who your key is. The answer names your organization and the scopes the key carries.
curl https://api.contactformoutreach.com/v1/me \ -H "Authorization: Bearer $API_KEY"const res = await fetch("https://api.contactformoutreach.com/v1/me" , { headers: { Authorization: `Bearer ${process.env.API_KEY}` },});const me = await res.json();console.log(me);Authentication
Make a key in the app under Settings › API keys. Give it a name, choose its scopes and, if you like, a date it stops working. The key is shown once, when it is made, so store it straight away. Send it on every call as a bearer token:
Authorization: Bearer <your key>A key works inside your organization. Keep it on your server: never put it in a web page or an app you hand to other people. If a key may have leaked, replace its secret or revoke it in the app, or with the API keys calls below.
Scopes
A key only does what its scopes allow. Anything else is refused with a 403, so give each key only what its job needs.
| Scope | What it allows |
|---|---|
read | Look at anything: campaigns, companies, results, contacts, replies, lists, events and settings. |
write | Make and change things: campaigns, lists, messages, sender details, do-not-contact, members and settings. |
sign_off | Approve a campaign's list. Nothing can be sent until its list is approved. |
send | Send a campaign whose list is approved, and nothing else. |
webhooks | Manage webhook endpoints and what was sent to them, reads included. |
Approval comes before sending
A list must be approved before it can be sent. This is a real safety step, not a formality: reading the approval gives you the wording to show the person who approves, and a fingerprint of the list as it is now. Send both back and you approve exactly the list that was reviewed. Approving and sending are separate scopes, so a key that builds campaigns can be given no power to send them.
# 1. Read what you are approving: the wording and a fingerprint of the listcurl https://api.contactformoutreach.com/v1/campaigns/$CAMPAIGN_ID/signoff \ -H "Authorization: Bearer $API_KEY"# 2. Approve it: send back the wording and list_hash you were givencurl -X POST https://api.contactformoutreach.com/v1/campaigns/$CAMPAIGN_ID/signoff \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signoff-$CAMPAIGN_ID-$LIST_HASH" \ -d '{"wording": "<the wording you showed>", "list_hash": "<list_hash>"}'# 3. Send it. The answer names the job that does the workcurl -X POST https://api.contactformoutreach.com/v1/campaigns/$CAMPAIGN_ID/send \ -H "Authorization: Bearer $API_KEY" \ -H "Idempotency-Key: send-$CAMPAIGN_ID"const api = async (path, init = {}) => { const res = await fetch(`https://api.contactformoutreach.com/v1${path}` , { ...init, headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json" , ...init.headers, }, }); const body = await res.json(); if (!res.ok) throw new Error(`${body.error.message} (${body.error.request_id})`); return body;};// 1. Read what you are approving: the wording and a fingerprint of the list. const signoff = await api(`/campaigns/${id}/signoff` );// 2. Approve exactly that list. Needs the sign_off scope. await api(`/campaigns/${id}/signoff` , { method: "POST", headers: { "Idempotency-Key": `signoff-${id}-${signoff.list_hash}` }, body: JSON.stringify({ wording: signoff.wording, list_hash: signoff.list_hash }),});// 3. Send. Needs the send scope. The answer names the job that does the work. const job = await api(`/campaigns/${id}/send` , { method: "POST", headers: { "Idempotency-Key": `send-${id}` },});Making changes safely
Every call that changes something (POST, PUT, PATCH and DELETE) needs an Idempotency-Key header: a value of 8 to 255 plain characters that you make once for each action, such as a UUID. If the network drops or you are not sure a call went through, send it again with the same key. You get the first answer back, and the work is done once.
- Use a new key for each new action, and the same key for each retry of that action.
- A 409 means a request with that key is still running, or the key was already used for a different request.
- Work that takes time answers
202 Acceptedand names the job doing it. Follow it at/v1/jobs/{id}.
curl https://api.contactformoutreach.com/v1/campaigns \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f9b6c1e-2d4a-4c8e-9a71-5b0e2f6d8c13" \ -d '{ "name": "Dental clinics, Germany", "keywords": ["dental clinic"], "country": "DE", "language": "de", "send_enquiries": true }'// Make the key once per action and keep it, so a retry sends the same one. const idempotencyKey = crypto.randomUUID();const res = await fetch("https://api.contactformoutreach.com/v1/campaigns" , { method: "POST", headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json" , "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ name: "Dental clinics, Germany", keywords: ["dental clinic"], country: "DE", language: "de", send_enquiries: true, }),});const { campaign_id } = await res.json();Paging
Lists answer one page at a time. Ask with limit (how many rows) and offset (how many to skip). The answer carries page with the limit, offset and total it used. total is null when a listing does not count its rows.
Each list has its own default and maximum: up to 200 rows a page on most lists, and 500 on a few.
curl "https://api.contactformoutreach.com/v1/campaigns?limit=50&offset=100" \ -H "Authorization: Bearer $API_KEY"const all = [];for (let offset = 0; ; ) { const res = await fetch(`https://api.contactformoutreach.com/v1/campaigns?limit=200&offset=${offset}` , { headers: { Authorization: `Bearer ${process.env.API_KEY}` }, }); const { campaigns, page } = await res.json(); all.push(...campaigns); offset += campaigns.length; // A short page is the last one. total can be null, so do not rely on it. if (campaigns.length < page.limit) break;}{ "campaigns": [ ... ], "page": { "limit": 50, "offset": 100, "total": 312 }, "request_id": "req_k3x9q2ab_7hd4pw"}Errors
Anything refused answers in the same shape. code is one word your code can branch on, message is a sentence you can show a person, and request_id is what to quote if you ask us about it. Some answers add details, such as the parameter at fault.
{ "error": { "code": "FORBIDDEN", "message": "This key is not allowed to do that.", "status": 403, "timestamp": "2026-10-09T10:30:00Z", "request_id": "req_k3x9q2ab_7hd4pw" }}| Status | What it means |
|---|---|
400 | The request could not be read. The answer names the parameter at fault and what it must be. |
401 | No key was sent, or the key is not valid, or it has expired. |
403 | The key's scopes do not allow this call. |
404 | There is no such thing, or it belongs to another organization. |
409 | A request with this Idempotency-Key is still running, or the key was already used for a different request. |
422 | The request was understood and refused. The message says why, in words you can act on. |
429 | Too many requests for this key. Wait for the seconds in Retry-After, then try again. |
Rate limits
Limits are counted per key. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, so you can see where you stand. Go over and the answer is a 429 with a Retry-After header: wait that many seconds, then try again.
# -i shows the headers of the answercurl -i https://api.contactformoutreach.com/v1/me \ -H "Authorization: Bearer $API_KEY"# X-RateLimit-Limit: ...# X-RateLimit-Remaining: ...# X-RateLimit-Reset: ...// Wait as long as Retry-After says, then send the same request again, // with the same Idempotency-Key, so the work is still done only once. async function call(url, init) { for (;;) { const res = await fetch(url, init); if (res.status !== 429) return res; const seconds = Number(res.headers.get("Retry-After")) || 1; await new Promise((done) => setTimeout(done, seconds * 1000)); }}Webhooks
A webhook tells your systems the moment something happens, so you do not have to keep asking. Add an endpoint in the app under Settings › Webhooks, or through the API, with an https address and the events it should hear about. The answer carries the endpoint's signing secret, shown once. Send a test event to check the address before real ones arrive.
curl https://api.contactformoutreach.com/v1/webhook-endpoints \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 0b7e7c52-crm-sync-endpoint" \ -d '{ "url": "https://example.com/hooks/outreach" , "event_types": ["campaign.discovered", "target.submitted", "response.recorded"], "description": "CRM sync" }'const res = await fetch("https://api.contactformoutreach.com/v1/webhook-endpoints" , { method: "POST", headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json" , "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ url: "https://example.com/hooks/outreach" , event_types: ["campaign.discovered", "target.submitted", "response.recorded"], description: "CRM sync", }),});// The signing secret is in this answer only. Store it now. const { id, secret } = await res.json();Events
Each event names the thing that changed and the state it moved to. GET /v1/event-types lists every type with what it means, and GET /v1/events keeps the log whether or not anything is listening, so your systems can catch up after a gap.
| Event | When it is sent |
|---|---|
campaign.discovered | A campaign found its companies, and its list waits for review. |
campaign.attested | A campaign's list was approved, so it can send. |
campaign.running | A campaign started sending, or carried on after a pause. |
campaign.paused | A campaign was paused. |
campaign.done | A campaign finished sending. |
campaign.cancelled | A campaign was stopped before it finished. |
target.submitted | A message reached a company through its contact form. |
target.failed | A message could not be sent to a company. |
target.blocked | A company's website stopped a message from getting through. |
response.recorded | A company answered: a reply, an automatic reply, a bounce or a request not to be contacted. |
An event is posted to your endpoint as JSON:
{ "id": "8d2f6a90-3c1b-4e57-9f0a-6b2d1c7e4a53", "type": "campaign.attested", "timestamp": "2026-10-09T10:30:00Z", "data": { "object": "campaign", "id": "123e4567-e89b-12d3-a456-426614174000", "campaign_id": "123e4567-e89b-12d3-a456-426614174000", "kind": null, "state": "attested", "previous_state": "discovered" }}Checking the signature
Every event is signed with your endpoint's secret, so you can be sure it came from us and was not changed on the way. Three headers come with it: webhook-id, webhook-timestamp and webhook-signature. The signature is an HMAC-SHA256 of the id, the timestamp and the raw body, joined with dots. Check it before you act on an event, and refuse anything that does not match.
import { createHmac, timingSafeEqual } from "node:crypto";// rawBody is the request body exactly as it arrived, before any JSON parsing. export function verify(rawBody, headers, secret) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; // Refuse events older than five minutes, so an old one cannot be replayed. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = createHmac("sha256", secret) .update(`${id}.${timestamp}.${rawBody}`) .digest(); // While an old secret still works, an event carries one signature per secret. return (headers["webhook-signature"] || "").split(" ").some((entry) => { const given = Buffer.from(entry.split(",")[1] || "", "base64"); return given.length === expected.length && timingSafeEqual(given, expected); });}When you get a new secret, you choose how long the old one keeps working, up to 72 hours. Until then, events are signed with both, so you can move your server over without missing any.
Retries and sending again
- Answer with any 2xx status as soon as you have the event. Do slow work after you answer.
- Anything else counts as a failure, and the event is posted again later. Each delivery shows its attempts, the status your server gave and when the next try is due.
- When every attempt fails, the delivery is given up. An endpoint that keeps failing is switched off, and you can switch it back on once your server is fixed.
- Send one event again, or everything that failed since a time you choose, from the app or the API.
- The same event can arrive more than once. Use its
idto do the work only once.
Endpoints
All 123 calls, grouped by what they work on. Each one shows the scope a key needs. Paths are relative to https://api.contactformoutreach.com; the part in braces is the id you fill in.
Campaigns
Start a campaign, choose where it searches, check the cost, approve its list and send it.
- GET
/Your campaigns, one page at a timev1/ campaigns read - POST
/Start a campaignv1/ campaigns write - GET
/One campaignv1/ campaigns/ {id} read - PATCH
/Change a campaignv1/ campaigns/ {id} write - PUT
/Set what a campaign may spend on assistancev1/ campaigns/ {id}/ assist-budget write - POST
/Copy a campaignv1/ campaigns/ {id}/ copies write - POST
/Copy a campaign to another countryv1/ campaigns/ {id}/ copies/ {country} write - GET
/What a campaign would costv1/ campaigns/ {id}/ cost-preview read - GET
/The work queued on a campaign, newest firstv1/ campaigns/ {id}/ jobs read - POST
/Queue work on a campaign: finding companies or reading their sitesv1/ campaigns/ {id}/ jobs write - GET
/The places a campaign searchesv1/ campaigns/ {id}/ locations read - PUT
/Set the places a campaign searchesv1/ campaigns/ {id}/ locations write - DELETE
/Drop one place from a campaignv1/ campaigns/ {id}/ locations/ {location_code} write - POST
/Try the companies a campaign missed againv1/ campaigns/ {id}/ retries write - POST
/Send a campaign whose list is approvedv1/ campaigns/ {id}/ send send - GET
/The follow-up steps of a campaignv1/ campaigns/ {id}/ sequence read - PUT
/Set the follow-up steps of a campaignv1/ campaigns/ {id}/ sequence write - GET
/What approving a campaign's list would coverv1/ campaigns/ {id}/ signoff read - POST
/Approve a campaign's list, so it can be sentv1/ campaigns/ {id}/ signoff sign_off - POST
/Stop a campaignv1/ campaigns/ {id}/ stop write - GET
/What a campaign with these numbers would set aside, before it existsv1/ cost-estimate read - GET
/The countries a campaign can be run inv1/ countries read - GET
/One piece of queued workv1/ jobs/ {id} read - GET
/When a country's companies are written tov1/ send-windows/ {country} read - GET
/The saved territoriesv1/ territories read - POST
/Save a territoryv1/ territories write - GET
/One saved territoryv1/ territories/ {id} read - DELETE
/Delete a territoryv1/ territories/ {id} write - GET
/How many companies a territory holdsv1/ territories/ estimate read - DELETE
/Clear the address checksv1/ url-tests write
Companies
The companies a campaign found, what happened with each, and who looks after them.
- GET
/Every company across your campaigns, with one status eachv1/ board read - GET
/The companies a campaign foundv1/ campaigns/ {id}/ targets read - POST
/Add one company to a campaign by addressv1/ campaigns/ {id}/ targets write - GET
/One company a campaign foundv1/ campaigns/ {id}/ targets/ {target_id} read - DELETE
/Take one company off a campaignv1/ campaigns/ {id}/ targets/ {target_id} write - GET
/The notes on one company of a campaignv1/ campaigns/ {id}/ targets/ {target_id}/ notes read - POST
/Add a note to a companyv1/ campaigns/ {id}/ targets/ {target_id}/ notes write - PUT
/Set or clear who looks after a companyv1/ campaigns/ {id}/ targets/ {target_id}/ owner write - POST
/Put back a company a reviewer took off a campaignv1/ campaigns/ {id}/ targets/ {target_id}/ restore write - GET
/The companies whose contact form has no captchav1/ captcha-free read - GET
/How big the captcha-free list is and how freshv1/ captcha-free/ summary read - GET
/What happened with one companyv1/ companies/ {domain}/ status read - DELETE
/Withdraw a notev1/ notes/ {id} write - GET
/What the companies' sites are built with and what failed on their sidev1/ site-facts read
Results and proof
What came back from a campaign, the contacts it collected and the proof kept for each delivery.
- GET
/Why companies on a campaign were not contactedv1/ campaigns/ {id}/ block-reasons read - GET
/The emails and phone numbers a campaign collectedv1/ campaigns/ {id}/ contacts read - GET
/What came back from a campaignv1/ campaigns/ {id}/ results read - GET
/Why a campaign is not movingv1/ campaigns/ {id}/ why-waiting read - GET
/The proof kept for one deliveryv1/ targets/ {id}/ evidence read
Replies
Replies matched to your campaigns, answers you record, who a reply goes to, and follow-ups done by hand.
- POST
/Record what a company answeredv1/ campaigns/ {id}/ targets/ {target_id}/ responses write - GET
/Mail matched to your campaigns: replies, automatic replies, bounces and opt-outsv1/ inbound-matches read - POST
/Hand in one mail from your forwarding addressv1/ inbound-matches write - GET
/Replies at a glance: your mailbox connection and counts for 7 and 30 daysv1/ inbound-matches/ summary read - DELETE
/Take back a recorded answerv1/ responses/ {id} write - GET
/Who a reply is handed to, and the latest decisionsv1/ routing-rules read - PUT
/Replace your reply routing rulesv1/ routing-rules write - GET
/The manual follow-ups that fell duev1/ todos read - POST
/Mark a manual follow-up donev1/ todos/ {id}/ done write
Lists
Saved lists of companies to reuse across campaigns.
- POST
/Add a saved list to a campaignv1/ campaigns/ {id}/ imports write - POST
/Save a campaign's companies as a listv1/ campaigns/ {id}/ saved-lists write - GET
/The saved listsv1/ lists read - POST
/Make a saved listv1/ lists write - GET
/One saved listv1/ lists/ {id} read - PUT
/Rename a saved listv1/ lists/ {id} write - DELETE
/Delete a saved listv1/ lists/ {id} write - POST
/Start a campaign from a saved listv1/ lists/ {id}/ campaigns write - GET
/The companies on a saved listv1/ lists/ {id}/ domains read - POST
/Add companies to a saved listv1/ lists/ {id}/ domains write - DELETE
/Take one company off a saved listv1/ lists/ {id}/ domains/ {domain} write - POST
/Take several companies off a saved listv1/ lists/ {id}/ domains/ remove write - POST
/Add a campaign's companies to a saved listv1/ lists/ {id}/ imports write - GET
/What two lists sharev1/ lists/ {id}/ overlap read
Messages
The messages you save, and the one a campaign sends.
- PUT
/Choose the message a campaign sendsv1/ campaigns/ {id}/ message write - GET
/The saved messagesv1/ templates read - POST
/Write a messagev1/ templates write - GET
/One saved messagev1/ templates/ {id} read - PUT
/Change a messagev1/ templates/ {id} write - DELETE
/Delete a messagev1/ templates/ {id} write
Sender details
The name, company and contact details a message is sent with.
- GET
/The saved sender detailsv1/ senders read - POST
/Save sender detailsv1/ senders write - GET
/One set of sender detailsv1/ senders/ {id} read - PUT
/Change sender detailsv1/ senders/ {id} write - DELETE
/Delete sender detailsv1/ senders/ {id} write
Do-not-contact
Companies your organization will never message, whatever campaign holds them.
- GET
/Your do-not-contact listv1/ do-not-contact read - POST
/Put companies on your do-not-contact listv1/ do-not-contact write - DELETE
/Take one company off your do-not-contact listv1/ do-not-contact/ {domain} write
Team and members
The people in your organization, their roles, invitations and what each has on their plate.
- GET
/Invitations not yet acceptedv1/ invitations read - POST
/Invite someone by emailv1/ invitations write - DELETE
/Cancel an invitationv1/ invitations/ {id} write - POST
/Send an invitation againv1/ invitations/ {id}/ resend write - GET
/The people in your organizationv1/ members read - PATCH
/Change someone's rolev1/ members/ {id} write - DELETE
/Remove someone from your organizationv1/ members/ {id} write - GET
/What each team member has on their platev1/ team read
Billing
Your balance, top-ups and every movement of money.
- GET
/The balance, what is set aside and the top-up settingsv1/ balance read - PATCH
/Change automatic top-up or the low balance emailv1/ balance write - POST
/Add money to the balancev1/ top-ups write - GET
/One top-upv1/ top-ups/ {id} read - GET
/Money in and out, newest firstv1/ transactions read
API keys
Who a key is, and the keys your organization holds.
- GET
/Your organization's API keysv1/ api-keys read - POST
/Make an API keyv1/ api-keys write - POST
/Revoke a keyv1/ api-keys/ {id}/ revoke write - POST
/Replace a key's secretv1/ api-keys/ {id}/ rotate write - GET
/Who this key isv1/ me read
Webhooks and events
Where your software is told what happened, what was sent to it, and the log of events.
- GET
/Every kind of event an endpoint can ask forv1/ event-types read - GET
/What has happened in your organization, newest firstv1/ events read - GET
/One thing that happenedv1/ events/ {id} read - GET
/One event on its way to one endpointv1/ webhook-deliveries/ {id} webhooks - POST
/Send one event to its endpoint againv1/ webhook-deliveries/ {id}/ resend webhooks - GET
/Your webhook endpointsv1/ webhook-endpoints webhooks - POST
/Add a webhook endpointv1/ webhook-endpoints webhooks - GET
/One webhook endpointv1/ webhook-endpoints/ {id} webhooks - PATCH
/Change an endpoint, or turn it on or offv1/ webhook-endpoints/ {id} webhooks - DELETE
/Delete a webhook endpointv1/ webhook-endpoints/ {id} webhooks - GET
/What has been sent to one endpoint, newest firstv1/ webhook-endpoints/ {id}/ deliveries webhooks - POST
/Send again everything that failed to an endpoint since a timev1/ webhook-endpoints/ {id}/ resend-failed webhooks - POST
/Give an endpoint a new secret, with the old one kept working for a whilev1/ webhook-endpoints/ {id}/ rotate-secret webhooks - POST
/Send one test event to an endpointv1/ webhook-endpoints/ {id}/ test webhooks
Exports
Contacts or companies as a CSV or Excel file.
- POST
/Export contacts or companies as a filev1/ exports write - GET
/One export, and its file when readyv1/ exports/ {id} read
Settings
Your organization's time zone and the daily digest.
- POST
/Send yourself today's digest as a testv1/ digest/ test write - GET
/Your organization's time zone and daily digest settingsv1/ settings read - PATCH
/Change your organization's time zone or daily digest settingsv1/ settings write
Same prices as the app
$0.09 for each form that goes through, whether the app or your software starts the campaign. Top up from $10, with no subscription and no API fee.